inti project

This commit is contained in:
root
2026-05-29 04:33:03 -04:00
commit cdeab1796f
699 changed files with 20516 additions and 0 deletions
@@ -0,0 +1,16 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use Tests\Architecture\Concerns\ScansArchitecture;
final class AttendanceBoundaryTest extends TestCase
{
use ScansArchitecture;
public function test_scanner_controller_delegates_and_does_not_calculate_status(): void
{
$this->assertFilesDoNotContain('app/Http/Controllers/Api/V2/Attendance', ['Badge::query', 'late', 'present', 'duplicate_of_id']);
}
}
@@ -0,0 +1,16 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use Tests\Architecture\Concerns\ScansArchitecture;
final class CommunicationBoundaryTest extends TestCase
{
use ScansArchitecture;
public function test_communication_controllers_do_not_loop_recipients_or_call_mail(): void
{
$this->assertFilesDoNotContain('app/Http/Controllers/Api/V2/Communication', ['foreach', 'Mail::', 'Twilio', 'WhatsApp']);
}
}
@@ -0,0 +1,41 @@
<?php declare(strict_types=1);
namespace Tests\Architecture\Concerns;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
trait ScansArchitecture
{
/** @return string[] */
protected function phpFiles(string $relative): array
{
$root = dirname(__DIR__, 3).'/'.$relative;
if (! is_dir($root)) {
return [];
}
$files = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)) as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
sort($files);
return $files;
}
protected function assertFilesDoNotContain(string $relative, array $needles, array $excludePathContains = []): void
{
foreach ($this->phpFiles($relative) as $file) {
foreach ($excludePathContains as $exclude) {
if (str_contains($file, $exclude)) {
continue 2;
}
}
$contents = file_get_contents($file) ?: '';
foreach ($needles as $needle) {
$this->assertStringNotContainsString($needle, $contents, $file.' contains forbidden pattern: '.$needle);
}
}
}
}
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use Tests\Architecture\Concerns\ScansArchitecture;
final class ControllerBoundaryTest extends TestCase
{
use ScansArchitecture;
public function test_controllers_do_not_call_external_providers_directly(): void
{
$this->assertFilesDoNotContain('app/Http/Controllers', ['Mail::', 'Notification::send', 'Twilio', 'WhatsApp']);
}
public function test_canonical_controllers_do_not_perform_obvious_domain_mutations(): void
{
$this->assertFilesDoNotContain('app/Http/Controllers/Api/V2', ['DB::table', 'Validator::make', 'Student::max', 'response()->download']);
}
}
@@ -0,0 +1,18 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class DeprecationPolicyTest extends TestCase
{
public function test_deprecation_document_exists_with_required_metadata_headings(): void
{
$path = dirname(__DIR__, 2).'/docs/deprecations.md';
$this->assertFileExists($path);
$contents = file_get_contents($path) ?: '';
foreach (['replacement_route', 'sunset_date', 'owner', 'removal_ticket'] as $required) {
$this->assertStringContainsString($required, $contents);
}
}
}
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use Tests\Architecture\Concerns\ScansArchitecture;
final class FinanceBoundaryTest extends TestCase
{
use ScansArchitecture;
public function test_finance_module_does_not_use_float_money_math(): void
{
$this->assertFilesDoNotContain('app/Domain/SchoolCore/Finance', ['(float)', 'floatval(']);
}
public function test_finance_controllers_do_not_download_payment_files_directly(): void
{
$this->assertFilesDoNotContain('app/Http/Controllers/Api/V2/Finance', ['response()->download']);
}
}
@@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class IslamicSundaySchoolBoundaryTest extends TestCase
{
public function test_extension_route_file_uses_domain_profile_middleware(): void
{
$routeFile = dirname(__DIR__, 2).'/routes/api_phase8.php';
$this->assertFileExists($routeFile);
$contents = file_get_contents($routeFile) ?: '';
$this->assertStringContainsString('islamic-sunday-school', $contents);
$this->assertStringContainsString('ensure.domain.profile:islamic_sunday_school', $contents);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use Tests\Architecture\Concerns\ScansArchitecture;
final class ModuleBoundaryTest extends TestCase
{
use ScansArchitecture;
public function test_school_core_does_not_depend_on_islamic_sunday_school(): void
{
$this->assertFilesDoNotContain('app/Domain/SchoolCore', ['App\\Domain\\IslamicSundaySchool']);
}
public function test_core_contracts_use_neutral_vocabulary(): void
{
$this->assertFilesDoNotContain('app/Domain/SchoolCore', ['Quran', "Qur'an", 'Halaqa', 'Masjid', 'Imam', 'Sadaqah', 'Zakat', 'ArabicLevel'], ['README.md']);
}
}
@@ -0,0 +1,40 @@
<?php
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class Phase10ReleaseReadinessArchitectureTest extends TestCase
{
public function test_release_docs_exist(): void
{
$required = [
'docs/release/feature-flag-rollout-map.md',
'docs/release/migration-validation.md',
'docs/release/shadow-comparison-plan.md',
'docs/release/rollback-playbooks.md',
'docs/release/uat-scenarios.md',
'docs/release/support-playbooks.md',
'docs/release/production-readiness-review.md',
];
foreach ($required as $path) {
$this->assertFileExists(base_path($path), $path);
}
}
public function test_release_commands_exist(): void
{
$required = [
'app/Console/Commands/ReleaseFeatureFlagsCommand.php',
'app/Console/Commands/ReleaseMigrationValidateCommand.php',
'app/Console/Commands/ReleaseShadowCompareCommand.php',
'app/Console/Commands/ReleaseReadinessGateCommand.php',
'app/Console/Commands/LegacyUnsafeRouteAuditCommand.php',
];
foreach ($required as $path) {
$this->assertFileExists(base_path($path), $path);
}
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
final class Phase2BoundaryArchitectureTest extends TestCase
{
public function test_school_core_does_not_import_extension_namespace(): void
{
$violations = $this->scan(app_path('Domain/SchoolCore'), ['App\\Domain\\IslamicSundaySchool']);
$this->assertSame([], $violations);
}
public function test_domain_services_do_not_accept_raw_request_or_call_auth(): void
{
$violations = $this->scan(app_path('Domain'), ['Illuminate\\Http\\Request', 'auth()']);
$violations = array_values(array_filter($violations, fn (string $path): bool => ! str_contains($path, 'SchoolContextResolver.php')));
$this->assertSame([], $violations);
}
public function test_core_contracts_keep_neutral_vocabulary(): void
{
$violations = $this->scan(app_path('Domain/SchoolCore/Contracts'), ['masjid', 'ministry', 'bible', 'servant', 'service_day']);
$this->assertSame([], $violations);
}
/** @param array<int, string> $needles @return array<int, string> */
private function scan(string $root, array $needles): array
{
if (! is_dir($root)) {
return [];
}
$violations = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)) as $file) {
if (! $file instanceof SplFileInfo || $file->getExtension() !== 'php') {
continue;
}
$contents = file_get_contents($file->getPathname()) ?: '';
foreach ($needles as $needle) {
if (str_contains($contents, $needle)) {
$violations[] = $file->getPathname() . ' contains ' . $needle;
}
}
}
return $violations;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
final class Phase3FinanceArchitectureTest extends TestCase
{
private function phpFiles(string $path): array
{
if (! is_dir($path)) { return []; }
$files = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)) as $file) { if ($file->isFile() && $file->getExtension() === 'php') { $files[] = $file->getPathname(); } }
return $files;
}
public function test_school_core_finance_does_not_depend_on_islamic_sunday_school(): void
{
foreach ($this->phpFiles(__DIR__ . '/../../app/Domain/SchoolCore/Finance') as $file) { $this->assertStringNotContainsString('App\\Domain\\IslamicSundaySchool', file_get_contents($file), $file); }
}
public function test_school_core_finance_does_not_use_request_or_auth_helpers(): void
{
foreach ($this->phpFiles(__DIR__ . '/../../app/Domain/SchoolCore/Finance') as $file) { $contents=file_get_contents($file); $this->assertStringNotContainsString('Illuminate\\Http\\Request', $contents, $file); $this->assertStringNotContainsString('auth()', $contents, $file); $this->assertStringNotContainsString('request()', $contents, $file); }
}
public function test_school_core_finance_avoids_float_casts(): void
{
foreach ($this->phpFiles(__DIR__ . '/../../app/Domain/SchoolCore/Finance') as $file) { $this->assertStringNotContainsString('(float)', file_get_contents($file), $file); }
}
public function test_no_active_filename_only_payment_file_route_in_phase3_routes(): void
{
$routes = file_get_contents(__DIR__ . '/../../routes/finance_phase3.php');
$this->assertStringNotContainsString("Route::get('payments/files/{filename}'", $routes);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class Phase4AttendanceArchitectureTest extends TestCase
{
public function test_school_core_attendance_does_not_depend_on_islamic_sunday_school(): void
{
foreach ($this->phpFiles('app/Domain/SchoolCore/Attendance') as $file) {
$contents = file_get_contents($file);
$this->assertStringNotContainsString('App\\Domain\\IslamicSundaySchool', $contents, $file);
}
}
public function test_school_core_attendance_does_not_use_request_or_auth_helpers(): void
{
foreach ($this->phpFiles('app/Domain/SchoolCore/Attendance') as $file) {
$contents = file_get_contents($file);
$this->assertStringNotContainsString('Illuminate\\Http\\Request', $contents, $file);
$this->assertStringNotContainsString('auth()', $contents, $file);
$this->assertStringNotContainsString('request()', $contents, $file);
}
}
public function test_school_core_attendance_contracts_remain_neutral(): void
{
$forbidden = ['masjid', 'ministry', 'quran', 'arabic', 'halaqa'];
foreach ($this->phpFiles('app/Domain/SchoolCore/Attendance/Contracts') as $file) {
$contents = strtolower((string) file_get_contents($file));
foreach ($forbidden as $word) {
$this->assertStringNotContainsString($word, $contents, $file);
}
}
}
public function test_scanner_controller_is_thin(): void
{
$file = dirname(__DIR__, 2).'/app/Http/Controllers/Attendance/ScannerController.php';
$contents = file_get_contents($file);
$this->assertStringNotContainsString('private function', $contents);
$this->assertStringContainsString('ScannerServiceContract', $contents);
}
private function phpFiles(string $relativePath): array
{
$base = dirname(__DIR__, 2).'/'.$relativePath;
if (! is_dir($base)) {
return [];
}
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base));
$files = [];
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
return $files;
}
}
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class Phase5StudentLifecycleArchitectureTest extends TestCase { private function phpFiles(string $path): array { $base=dirname(__DIR__,2).'/'.$path; if(!is_dir($base)){return [];} $it=new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base)); $files=[]; foreach($it as $f){ if($f->isFile()&&$f->getExtension()==='php'){$files[]=$f->getPathname();} } return $files; } public function test_school_core_students_does_not_depend_on_islamic_sunday_school(): void { foreach($this->phpFiles('app/Domain/SchoolCore/Students') as $file){$this->assertStringNotContainsString('App\\Domain\\IslamicSundaySchool',file_get_contents($file),$file);} } public function test_school_core_students_does_not_depend_on_request_or_auth(): void { foreach($this->phpFiles('app/Domain/SchoolCore/Students') as $file){$c=file_get_contents($file); $this->assertStringNotContainsString('Illuminate\\Http\\Request',$c,$file); $this->assertStringNotContainsString('auth()',$c,$file); $this->assertStringNotContainsString('request()',$c,$file);} } public function test_core_student_contracts_stay_neutral(): void { foreach($this->phpFiles('app/Domain/SchoolCore/Students/Contracts') as $file){$c=strtolower(file_get_contents($file)); foreach(['masjid','quran','arabic','halaqa','ministry','community','islamic_studies'] as $word){$this->assertStringNotContainsString($word,$c,$file);}} } }
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace Tests\Architecture; use PHPUnit\Framework\TestCase; final class Phase6CommunicationArchitectureTest extends TestCase { public function test_school_core_communication_has_no_extension_import(): void { $it=new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(__DIR__.'/../../app/Domain/SchoolCore/Communication')); foreach($it as $file){ if(!$file->isFile()||$file->getExtension()!=='php') continue; $c=file_get_contents($file->getPathname()); $this->assertStringNotContainsString('App\\Domain\\IslamicSundaySchool',$c,$file->getPathname()); $this->assertStringNotContainsString('Illuminate\\Http\\Request',$c,$file->getPathname()); $this->assertStringNotContainsString('auth()',$c,$file->getPathname()); $this->assertStringNotContainsString('request()',$c,$file->getPathname()); } } public function test_core_contracts_are_neutral(): void { foreach(glob(__DIR__.'/../../app/Domain/SchoolCore/Communication/Contracts/*.php')?:[] as $file){ $c=strtolower(file_get_contents($file)); foreach(['sunday','masjid','ministry','halaqa','quran'] as $word){ $this->assertStringNotContainsString($word,$c,$file); } } } }
@@ -0,0 +1,41 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class Phase7ReportingArchitectureTest extends TestCase
{
private function files(string $path): array
{
$base = dirname(__DIR__, 2).'/app/'.$path;
return glob($base.'/**/*.php') ?: [];
}
public function test_school_core_reporting_does_not_depend_on_islamic_sunday_school(): void
{
foreach ($this->files('Domain/SchoolCore/Reporting') as $file) {
$contents = file_get_contents($file);
$this->assertStringNotContainsString('App\\Domain\\IslamicSundaySchool', $contents, $file);
}
}
public function test_reporting_domain_does_not_use_request_or_auth_helpers(): void
{
foreach ($this->files('Domain/SchoolCore/Reporting') as $file) {
$contents = file_get_contents($file);
$this->assertStringNotContainsString('Illuminate\\Http\\Request', $contents, $file);
$this->assertStringNotContainsString('auth()', $contents, $file);
$this->assertStringNotContainsString('request()', $contents, $file);
}
}
public function test_core_reporting_contracts_do_not_contain_extension_vocabulary(): void
{
$forbidden = ['quran', 'halaqa', 'masjid', 'ministry'];
foreach ($this->files('Domain/SchoolCore/Reporting/Contracts') as $file) {
$contents = strtolower(file_get_contents($file));
foreach ($forbidden as $term) {
$this->assertStringNotContainsString($term, $contents, $file);
}
}
}
}
@@ -0,0 +1,69 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
final class Phase8ControllerApiArchitectureTest extends TestCase
{
/** @return string[] */
private function phpFiles(string $relative): array
{
$root = dirname(__DIR__, 2).'/'.$relative;
if (! is_dir($root)) {
return [];
}
$files = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)) as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
return $files;
}
public function test_canonical_v2_controllers_do_not_use_raw_db_or_provider_sdks(): void
{
foreach ($this->phpFiles('app/Http/Controllers/Api/V2') as $file) {
$contents = file_get_contents($file);
$this->assertStringNotContainsString('DB::table', $contents, $file);
$this->assertStringNotContainsString('Mail::', $contents, $file);
$this->assertStringNotContainsString('Validator::make', $contents, $file);
$this->assertStringNotContainsString('max(\'id\')', $contents, $file);
$this->assertStringNotContainsString('response()->download', $contents, $file);
}
}
public function test_schoolcore_controllers_do_not_import_islamic_sunday_school_extension_classes(): void
{
foreach ($this->phpFiles('app/Http/Controllers/Api/V2') as $file) {
if (str_contains($file, 'IslamicSundaySchool')) {
continue;
}
$contents = file_get_contents($file);
$this->assertStringNotContainsString('App\\Domain\\IslamicSundaySchool', $contents, $file);
}
}
public function test_bulk_send_controller_requires_preview_dto_path(): void
{
$file = dirname(__DIR__, 2).'/app/Http/Controllers/Api/V2/Communication/BulkSendController.php';
$contents = file_get_contents($file);
$this->assertStringContainsString('BulkSendMessageRequest', $contents);
$this->assertStringContainsString('sendBulkMessage', $contents);
$this->assertStringNotContainsString('foreach', $contents, 'Bulk controller must not loop recipients.');
}
public function test_reporting_controllers_delegate_to_reporting_contracts(): void
{
$path = dirname(__DIR__, 2).'/app/Http/Controllers/Api/V2/Reporting';
foreach ($this->phpFiles('app/Http/Controllers/Api/V2/Reporting') as $file) {
$contents = file_get_contents($file);
$this->assertStringNotContainsString('select(', $contents, $file);
$this->assertStringNotContainsString('join(', $contents, $file);
$this->assertStringContainsString('Contract', $contents, $file);
}
}
}
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use Tests\Architecture\Concerns\ScansArchitecture;
final class ReportingBoundaryTest extends TestCase
{
use ScansArchitecture;
public function test_reporting_controllers_do_not_build_sql_or_export_directly(): void
{
$this->assertFilesDoNotContain('app/Http/Controllers/Api/V2/Reporting', ['DB::', 'select(', 'join(', 'fputcsv', 'Xlsx']);
}
public function test_schoolcore_reporting_provider_does_not_register_extension_reports(): void
{
$this->assertFilesDoNotContain('app/Providers', ['IslamicSundaySchool\\Reporting\\Reports'], ['IslamicSundaySchoolReportingServiceProvider.php']);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class RouteInventoryTest extends TestCase
{
public function test_generated_route_inventory_exists_and_has_governance_fields(): void
{
$path = dirname(__DIR__, 2).'/storage/app/generated/api-route-inventory.json';
$this->assertFileExists($path);
$rows = json_decode(file_get_contents($path) ?: '[]', true);
$this->assertIsArray($rows);
foreach ($rows as $row) {
$this->assertArrayHasKey('module', $row);
$this->assertArrayHasKey('canonical_status', $row);
$this->assertArrayHasKey('risk', $row);
}
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
final class SchoolCoreArchitectureTest extends TestCase
{
public function test_school_core_does_not_use_hidden_context_sources(): void
{
$forbidden = ['auth(', 'request(', 'Configuration::getConfig(', 'App\\Domain\\IslamicSundaySchool'];
$root = app_path('Domain/SchoolCore');
if (! is_dir($root)) {
$this->markTestSkipped('SchoolCore directory not present in this test installation.');
}
$violations = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)) as $file) {
if (! $file instanceof SplFileInfo || $file->getExtension() !== 'php') {
continue;
}
$contents = file_get_contents($file->getPathname()) ?: '';
foreach ($forbidden as $needle) {
if (str_contains($contents, $needle)) {
$violations[] = $file->getPathname() . ' contains ' . $needle;
}
}
}
$this->assertSame([], $violations);
}
}
@@ -0,0 +1,16 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use Tests\Architecture\Concerns\ScansArchitecture;
final class ServiceBoundaryTest extends TestCase
{
use ScansArchitecture;
public function test_domain_services_do_not_read_http_or_global_request_state(): void
{
$this->assertFilesDoNotContain('app/Domain', ['Illuminate\\Http\\Request', 'auth()', 'request()', 'session()', '$_GET', '$_POST', '$_SERVER']);
}
}
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace Tests\Architecture;
use PHPUnit\Framework\TestCase;
use Tests\Architecture\Concerns\ScansArchitecture;
final class StudentBoundaryTest extends TestCase
{
use ScansArchitecture;
public function test_student_identifier_generation_does_not_use_max_id(): void
{
$this->assertFilesDoNotContain('app', ["max('id')", 'max("id")', 'MAX(id)']);
}
public function test_core_student_layer_has_no_extension_profile_fields(): void
{
$this->assertFilesDoNotContain('app/Domain/SchoolCore/Students', ['quran_level', 'arabic_level', 'halaqa', 'masjid_family', 'imam_note']);
}
}
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Support\Api\ApiResponse;
use PHPUnit\Framework\TestCase;
final class Phase8ApiEnvelopeTest extends TestCase
{
public function test_api_response_class_exists(): void
{
$this->assertTrue(class_exists(ApiResponse::class));
}
public function test_route_inventory_fixture_exists(): void
{
$path = dirname(__DIR__, 3).'/storage/app/generated/api-route-inventory.json';
$this->assertFileExists($path);
$this->assertStringContainsString('api.v2', file_get_contents($path));
}
}
@@ -0,0 +1,22 @@
<?php
namespace Tests\Feature\Release;
use App\Support\Release\FeatureFlagRegistry;
use PHPUnit\Framework\TestCase;
final class FeatureFlagRolloutMapTest extends TestCase
{
public function test_high_risk_flags_have_owners_and_safe_defaults(): void
{
$registry = new FeatureFlagRegistry();
$flags = $registry->export();
$this->assertNotEmpty($flags);
foreach ($flags as $flag) {
$this->assertNotSame('', $flag['owner']);
$this->assertArrayHasKey('default', $flag);
}
}
}
@@ -0,0 +1,22 @@
<?php
namespace Tests\Feature\Release;
use App\Support\Release\MigrationValidationRunner;
use PHPUnit\Framework\TestCase;
final class MigrationValidationScaffoldTest extends TestCase
{
public function test_validation_runner_defines_required_module_checks(): void
{
$runner = new MigrationValidationRunner();
$modules = array_map(fn ($result): string => $result->module, $runner->runAll());
$this->assertContains('finance', $modules);
$this->assertContains('attendance', $modules);
$this->assertContains('students', $modules);
$this->assertContains('communication', $modules);
$this->assertContains('reporting', $modules);
$this->assertContains('islamic_sunday_school', $modules);
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\SchoolCore\Attendance;
use PHPUnit\Framework\TestCase;
final class ScannerDelegationTest extends TestCase
{
public function test_scanner_route_file_exists_for_phase_four_slice(): void
{
$routeFile = dirname(__DIR__, 3).'/routes/attendance_phase4.php';
$this->assertFileExists($routeFile);
$this->assertStringContainsString('attendance.scan.process', file_get_contents($routeFile));
}
}
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace Tests\Feature\SchoolCore\Communication; use App\Domain\SchoolCore\Communication\Exceptions\RecipientPreviewRequiredException; use PHPUnit\Framework\TestCase; final class BulkSendRequiresPreviewTest extends TestCase { public function test_bulk_send_without_preview_exception_exists(): void { $this->assertTrue(class_exists(RecipientPreviewRequiredException::class)); } }
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\SchoolCore\Finance;
use PHPUnit\Framework\TestCase;
final class PaymentFileRouteTest extends TestCase
{
public function test_phase3_route_documents_payment_id_based_file_access(): void
{
$routes = file_get_contents(__DIR__ . '/../../../routes/finance_phase3.php');
$this->assertStringContainsString('payments/{payment}/file', $routes);
$this->assertStringContainsString('school.context', $routes);
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\SchoolCore;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Files\Policies\PaymentFileAccessPolicy;
use PHPUnit\Framework\TestCase;
final class PaymentFilePolicyTest extends TestCase
{
public function test_cross_school_payment_file_access_fails(): void
{
$context = new SchoolContext(10, 20, ['finance'], '2025', 'spring', 'UTC', 'en', 'USD', 'standard_school');
$policy = new PaymentFileAccessPolicy();
$this->assertFalse($policy->canAccess($context, 'payment', 99, ['school_id' => 11]));
}
public function test_finance_role_can_access_same_school_payment_file(): void
{
$context = new SchoolContext(10, 20, ['finance'], '2025', 'spring', 'UTC', 'en', 'USD', 'standard_school');
$policy = new PaymentFileAccessPolicy();
$this->assertTrue($policy->canAccess($context, 'payment', 99, ['school_id' => 10]));
}
}
@@ -0,0 +1,14 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class ReportingPhase7ScaffoldTest extends TestCase
{
public function test_reporting_phase_7_scaffold_exists(): void
{
$root = dirname(__DIR__, 3);
$this->assertFileExists($root.'/app/Domain/SchoolCore/Reporting/Contracts/ReportRunnerContract.php');
$this->assertFileExists($root.'/app/Providers/SchoolCoreReportingServiceProvider.php');
$this->assertFileExists($root.'/app/Domain/IslamicSundaySchool/Reporting/Providers/IslamicSundaySchoolReportingServiceProvider.php');
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\SchoolCore;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Http\Middleware\ResolveSchoolContext;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Orchestra\Testbench\TestCase;
final class ResolveSchoolContextMiddlewareTest extends TestCase
{
protected function getPackageProviders($app): array
{
return [\App\Providers\SchoolContextServiceProvider::class];
}
public function test_it_attaches_context_to_request(): void
{
$request = Request::create('/test', 'GET');
$request->setUserResolver(fn () => new MiddlewareFakeUser(5, 88));
$middleware = app(ResolveSchoolContext::class);
$response = $middleware->handle($request, function (Request $request) {
return response()->json([
'school_id' => $request->attributes->get(SchoolContext::class)->schoolId,
]);
});
$this->assertSame(200, $response->getStatusCode());
$this->assertStringContainsString('5', $response->getContent());
}
}
final class MiddlewareFakeUser implements Authenticatable
{
public function __construct(public int $school_id, private int $id)
{
}
public function getAuthIdentifierName(): string { return 'id'; }
public function getAuthIdentifier(): mixed { return $this->id; }
public function getAuthPassword(): string { return ''; }
public function getAuthPasswordName(): string { return 'password'; }
public function getRememberToken(): ?string { return null; }
public function setRememberToken($value): void {}
public function getRememberTokenName(): string { return 'remember_token'; }
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\SchoolCore;
use PHPUnit\Framework\TestCase;
final class StudentIdentifierConcurrencyTest extends TestCase
{
public function test_database_must_enforce_unique_student_identifier_per_school(): void
{
$this->markTestSkipped('Enable in the host Laravel app after binding the real Student model and database connection. The migration adds unique(school_id, school_id_number).');
}
}
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\SchoolCore\Students;
use PHPUnit\Framework\TestCase;
final class StudentLifecycleDelegationTest extends TestCase { public function test_phase5_routes_file_exists(): void { $this->assertFileExists(dirname(__DIR__,3).'/routes/student_lifecycle_phase5.php'); } }
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\IslamicSundaySchool\Attendance;
use App\Domain\IslamicSundaySchool\Attendance\Policies\IslamicSundaySchoolAttendanceStatusPolicy;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Context\SchoolContext;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
final class IslamicSundaySchoolAttendanceStatusPolicyTest extends TestCase
{
public function test_extension_can_use_different_late_window_without_changing_core(): void
{
$context = new SchoolContext(1, 10, [], '2026', 'spring', 'UTC', 'en', 'USD', 'islamic_sunday_school');
$session = new AttendanceSessionView(1, 1, 'Program Session', 'program', new DateTimeImmutable('2026-05-29 09:00:00'), new DateTimeImmutable('2026-05-29 12:00:00'), 'UTC');
$policy = new IslamicSundaySchoolAttendanceStatusPolicy();
$this->assertSame('present', $policy->statusForScan($context, $session, new DateTimeImmutable('2026-05-29 09:14:00'))->status);
$this->assertSame('late', $policy->statusForScan($context, $session, new DateTimeImmutable('2026-05-29 09:16:00'))->status);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\IslamicSundaySchool;
use App\Domain\IslamicSundaySchool\Attendance\IslamicSundaySchoolAttendancePolicy;
use App\Domain\SchoolCore\Context\SchoolContext;
use PHPUnit\Framework\TestCase;
final class AttendancePolicyTest extends TestCase
{
public function test_it_treats_sunday_as_school_day(): void
{
$context = new SchoolContext(1, 2, [], '2025', 'spring', 'UTC', 'en', 'USD', 'islamic_sunday_school');
$policy = new IslamicSundaySchoolAttendancePolicy();
$this->assertTrue($policy->isSchoolDay($context, new \DateTimeImmutable('2026-05-31')));
$this->assertFalse($policy->isSchoolDay($context, new \DateTimeImmutable('2026-05-30')));
}
}
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace Tests\Unit\IslamicSundaySchool\Communication; use App\Domain\IslamicSundaySchool\Communication\Templates\IslamicSundaySchoolTemplateCatalog; use PHPUnit\Framework\TestCase; final class IslamicSundaySchoolCommunicationExtensionTest extends TestCase { public function test_template_catalog_is_extension_owned(): void { $this->assertArrayHasKey('program_class_reminder',(new IslamicSundaySchoolTemplateCatalog())->templates()); } }
@@ -0,0 +1,14 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use App\Domain\IslamicSundaySchool\Reporting\Reports\QuranProgressReport;
final class IslamicSundaySchoolReportAvailabilityTest extends TestCase
{
public function test_islamic_report_only_available_for_islamic_profile(): void
{
$report = new QuranProgressReport();
$this->assertTrue($report->availableForDomainProfile('islamic_sunday_school'));
$this->assertFalse($report->availableForDomainProfile('standard_school'));
}
}
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\IslamicSundaySchool\Students;
use App\Domain\IslamicSundaySchool\Students\Policies\IslamicSundaySchoolStudentIdentifierGenerator; use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Students\DTO\StudentView; use PHPUnit\Framework\TestCase;
final class IslamicSundaySchoolStudentIdentifierGeneratorTest extends TestCase { public function test_generates_extension_identifier_without_changing_core(): void { $ctx=new SchoolContext(1,9,[1],'2026','spring','America/New_York','en','USD','islamic_sunday_school'); $id=(new IslamicSundaySchoolStudentIdentifierGenerator())->generateForPersistedStudent($ctx,new StudentView(55,1,null,'A','B')); $this->assertStringStartsWith('SS-',(string)$id); $this->assertStringEndsWith('-000055',(string)$id); } }
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SchoolCore\Attendance;
use App\Domain\SchoolCore\Attendance\DTO\AttendanceSessionView;
use App\Domain\SchoolCore\Attendance\Policies\DefaultAttendanceStatusPolicy;
use App\Domain\SchoolCore\Context\SchoolContext;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
final class DefaultAttendanceStatusPolicyTest extends TestCase
{
public function test_status_becomes_late_after_default_threshold(): void
{
$context = new SchoolContext(1, 10, [], '2026', 'spring', 'UTC', 'en', 'USD', 'standard_school');
$session = new AttendanceSessionView(1, 1, 'Morning', 'daily', new DateTimeImmutable('2026-05-29 09:00:00'), new DateTimeImmutable('2026-05-29 12:00:00'), 'UTC');
$policy = new DefaultAttendanceStatusPolicy();
$this->assertSame('present', $policy->statusForScan($context, $session, new DateTimeImmutable('2026-05-29 09:05:00'))->status);
$this->assertSame('late', $policy->statusForScan($context, $session, new DateTimeImmutable('2026-05-29 09:11:00'))->status);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SchoolCore\Attendance;
use App\Domain\SchoolCore\Attendance\DTO\ProcessScanData;
use App\Domain\SchoolCore\Attendance\Support\ScanFingerprint;
use App\Domain\SchoolCore\Context\SchoolContext;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
final class ScanFingerprintTest extends TestCase
{
public function test_scan_fingerprint_is_school_scoped(): void
{
$scan = new ProcessScanData('ABC123', 'front', 'check_in', 'scanner', new DateTimeImmutable('2026-05-29 09:00:00'));
$a = new SchoolContext(1, 10, [], '2026', 'spring', 'UTC', 'en', 'USD', 'standard_school');
$b = new SchoolContext(2, 10, [], '2026', 'spring', 'UTC', 'en', 'USD', 'standard_school');
$this->assertNotSame(
ScanFingerprint::fromScan($a, $scan, 90),
ScanFingerprint::fromScan($b, $scan, 90)
);
}
}
@@ -0,0 +1 @@
<?php declare(strict_types=1); namespace Tests\Unit\SchoolCore\Communication; use App\Domain\SchoolCore\Communication\DTO\RecipientData; use App\Domain\SchoolCore\Communication\Services\RecipientDedupeService; use App\Domain\SchoolCore\Communication\Support\RecipientSet; use PHPUnit\Framework\TestCase; final class RecipientDedupeServiceTest extends TestCase { public function test_dedupes_same_contact(): void { $r=new RecipientData('guardian',1,'Guardian','same@example.test',null,null,[],null,null,10,['email']); $result=(new RecipientDedupeService())->dedupe(new RecipientSet([$r,$r])); $this->assertSame(1,$result->count()); $this->assertNotEmpty($result->excluded); } }
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SchoolCore\Context;
use App\Domain\SchoolCore\Context\SchoolContext;
use PHPUnit\Framework\TestCase;
final class SchoolContextTest extends TestCase
{
public function test_it_builds_audit_payload(): void
{
$context = new SchoolContext(
schoolId: 12,
actorUserId: 44,
actorRoleIds: [1, 2],
schoolYear: '2025-2026',
term: 'spring',
timezone: 'America/New_York',
locale: 'en',
currency: 'USD',
domainProfile: SchoolContext::DOMAIN_ISLAMIC_SUNDAY_SCHOOL,
);
$this->assertTrue($context->isIslamicSundaySchool());
$this->assertSame(12, $context->auditPayload()['school_id']);
$this->assertSame('spring', $context->term);
$this->assertSame('fall', $context->withTerm('fall')->term);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SchoolCore\Context;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Context\SchoolContextException;
use App\Domain\SchoolCore\Context\SchoolContextValidator;
use Illuminate\Contracts\Auth\Authenticatable;
use Orchestra\Testbench\TestCase;
final class SchoolContextValidatorTest extends TestCase
{
public function test_it_rejects_invalid_timezone(): void
{
$this->expectException(SchoolContextException::class);
$validator = new SchoolContextValidator();
$validator->assertValid($this->context(timezone: 'Not/AZone'), new FakeActor(10, 99));
}
public function test_it_rejects_wrong_school_actor(): void
{
$this->expectException(SchoolContextException::class);
$validator = new SchoolContextValidator();
$validator->assertValid($this->context(schoolId: 20), new FakeActor(10, 99));
}
private function context(int $schoolId = 10, string $timezone = 'UTC'): SchoolContext
{
return new SchoolContext(
schoolId: $schoolId,
actorUserId: 99,
actorRoleIds: [],
schoolYear: '2025-2026',
term: 'spring',
timezone: $timezone,
locale: 'en',
currency: 'USD',
domainProfile: SchoolContext::DOMAIN_STANDARD_SCHOOL,
);
}
}
final class FakeActor implements Authenticatable
{
public function __construct(public int $school_id, private int $id)
{
}
public function getAuthIdentifierName(): string { return 'id'; }
public function getAuthIdentifier(): mixed { return $this->id; }
public function getAuthPassword(): string { return ''; }
public function getAuthPasswordName(): string { return 'password'; }
public function getRememberToken(): ?string { return null; }
public function setRememberToken($value): void {}
public function getRememberTokenName(): string { return 'remember_token'; }
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SchoolCore\Finance;
use App\Domain\SchoolCore\Finance\Services\BalanceCalculator;
use PHPUnit\Framework\TestCase;
final class BalanceCalculatorTest extends TestCase
{
public function test_payment_reduces_balance(): void
{
$result = (new BalanceCalculator())->calculate(['currency'=>'USD'], [['amount_minor'=>10000,'type'=>'charge']], [['amount_minor'=>4000,'status'=>'recorded']]);
$this->assertSame(10000, $result['total_amount_minor']);
$this->assertSame(4000, $result['paid_amount_minor']);
$this->assertSame(6000, $result['balance_amount_minor']);
$this->assertSame('partial', $result['status']);
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SchoolCore\Finance;
use App\Domain\SchoolCore\Finance\Support\Money;
use PHPUnit\Framework\TestCase;
final class MoneyTest extends TestCase
{
public function test_money_adds_and_compares_without_float_math(): void
{
$a = new Money(12500, 'USD'); $b = new Money(2500, 'USD');
$this->assertSame(15000, $a->add($b)->minorAmount());
$this->assertSame(10000, $a->subtract($b)->minorAmount());
$this->assertSame(1, $a->compare($b));
$this->assertSame('125.00', $a->toDecimalString());
}
}
@@ -0,0 +1,14 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use App\Domain\SchoolCore\Reporting\Services\ReportRegistry;
use App\Domain\SchoolCore\Reporting\Reports\Students\StudentRosterReport;
final class ReportRegistryTest extends TestCase
{
public function test_registers_and_returns_report_by_key(): void
{
$registry = new ReportRegistry([new StudentRosterReport()]);
$this->assertSame('students.roster', $registry->get('students.roster')->key());
}
}
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SchoolCore\Students;
use App\Domain\SchoolCore\Context\SchoolContext; use App\Domain\SchoolCore\Students\DTO\StudentView; use App\Domain\SchoolCore\Students\Policies\DefaultStudentIdentifierGenerator; use PHPUnit\Framework\TestCase;
final class DefaultStudentIdentifierGeneratorTest extends TestCase { public function test_generates_identifier_from_persisted_student_id(): void { $ctx=new SchoolContext(1,9,[1],'2026','spring','America/New_York','en','USD','standard_school'); $id=(new DefaultStudentIdentifierGenerator())->generateForPersistedStudent($ctx,new StudentView(123,1,null,'A','B')); $this->assertSame('SCH-0000123',(string)$id); } }
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SchoolCore\Students;
use App\Domain\SchoolCore\Context\SchoolContext;
use App\Domain\SchoolCore\Students\Services\SequentialStudentIdentifierGenerator;
use PHPUnit\Framework\TestCase;
final class SequentialStudentIdentifierGeneratorTest extends TestCase
{
public function test_it_generates_identifier_after_student_id_exists(): void
{
$context = new SchoolContext(12, 44, [], '2025', 'spring', 'UTC', 'en', 'USD', 'standard_school');
$identifier = (new SequentialStudentIdentifierGenerator())->generate($context, 991);
$this->assertSame('12-000991', $identifier);
}
}
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\SchoolCore\Students;
use App\Domain\SchoolCore\Students\Exceptions\InvalidStudentStatusTransitionException; use App\Domain\SchoolCore\Students\Support\StudentLifecycleStateMachine; use PHPUnit\Framework\TestCase;
final class StudentLifecycleStateMachineTest extends TestCase { public function test_allows_valid_transition(): void { (new StudentLifecycleStateMachine())->assertCanTransition('active','withdrawn'); $this->assertTrue(true); } public function test_rejects_invalid_transition(): void { $this->expectException(InvalidStudentStatusTransitionException::class); (new StudentLifecycleStateMachine())->assertCanTransition('archived','active'); } }