update project

This commit is contained in:
root
2026-05-30 01:11:35 -04:00
parent 3a0628ecc7
commit 2225f6bc72
9743 changed files with 1122482 additions and 59 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,333 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Integration;
use Illuminate\Auth\GenericUser;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
final class AuthTenantIsolationIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_access_own_school_workflows(): void
{
$school = $this->createSchool('Own School', 'OWN');
$user = $this->createActorForSchool($school);
$this->actingAs($user);
$this->getJson("/api/schools/{$school}")
->assertOk();
$studentResponse = $this->postJson("/api/schools/{$school}/students", [
'first_name' => 'Maya',
'last_name' => 'Patel',
'student_number' => 'OWN-1001',
'grade_level' => '7',
]);
$studentResponse->assertCreated();
$studentId = $studentResponse->json('data.id');
$this->getJson("/api/schools/{$school}/students/{$studentId}")
->assertOk()
->assertJsonPath('data.student_number', 'OWN-1001');
$this->postJson("/api/schools/{$school}/attendance/scans", [
'student_number' => 'OWN-1001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
])->assertCreated();
$this->createTemplate(
$school,
'attendance_absence',
'email',
'Hello, {{ student.first_name }} was marked absent.'
);
$this->postJson("/api/schools/{$school}/communications/preview", [
'template_key' => 'attendance_absence',
'student_id' => $studentId,
'channel' => 'email',
])->assertOk();
$this->postJson("/api/schools/{$school}/communications/send", [
'template_key' => 'attendance_absence',
'student_id' => $studentId,
'channel' => 'email',
])->assertAccepted();
$file = UploadedFile::fake()->createWithContent(
'payments.csv',
"student_number,amount,paid_at,reference\nOWN-1001,125.00,2026-05-29,PAY-OWN-001\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $file,
])->assertCreated();
$this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29")
->assertOk();
}
public function test_user_cannot_access_another_schools_data(): void
{
$schoolA = $this->createSchool('School A', 'S-A');
$schoolB = $this->createSchool('School B', 'S-B');
$userA = $this->createActorForSchool($schoolA);
$studentB = $this->createStudent($schoolB, 'OTHER-2001', 'Other');
$this->createTemplate(
$schoolB,
'attendance_absence',
'email',
'Hello, {{ student.first_name }} was marked absent.'
);
$this->actingAs($userA);
$this->assertTenantBlocked(
$this->getJson("/api/schools/{$schoolB}")
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/students", [
'first_name' => 'Intruder',
'last_name' => 'Student',
'student_number' => 'BAD-1001',
'grade_level' => '8',
])
);
$this->assertTenantBlocked(
$this->getJson("/api/schools/{$schoolB}/students/{$studentB}")
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/attendance/scans", [
'student_number' => 'OTHER-2001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
])
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/communications/preview", [
'template_key' => 'attendance_absence',
'student_id' => $studentB,
'channel' => 'email',
])
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/communications/send", [
'template_key' => 'attendance_absence',
'student_id' => $studentB,
'channel' => 'email',
])
);
$file = UploadedFile::fake()->createWithContent(
'payments.csv',
"student_number,amount,paid_at,reference\nOTHER-2001,125.00,2026-05-29,PAY-BAD-001\n"
);
$this->assertTenantBlocked(
$this->postJson("/api/schools/{$schoolB}/finance/payment-files", [
'file' => $file,
])
);
$this->assertTenantBlocked(
$this->getJson("/api/schools/{$schoolB}/reports/daily?date=2026-05-29")
);
$this->assertDatabaseMissing('students', [
'school_id' => $schoolB,
'student_identifier' => 'BAD-1001',
]);
$this->assertDatabaseMissing('payments', [
'school_id' => $schoolB,
'reference_number' => 'PAY-BAD-001',
]);
}
public function test_guest_cannot_access_school_workflows(): void
{
$school = $this->createSchool('Guest Block School', 'GST');
$student = $this->createStudent($school, 'GUEST-1001', 'Guest');
$this->createTemplate(
$school,
'attendance_absence',
'email',
'Hello, {{ student.first_name }} was marked absent.'
);
$this->getJson("/api/schools/{$school}")
->assertUnauthorized();
$this->postJson("/api/schools/{$school}/students", [
'first_name' => 'Guest',
'last_name' => 'Student',
'student_number' => 'GUEST-2001',
'grade_level' => '7',
])->assertUnauthorized();
$this->getJson("/api/schools/{$school}/students/{$student}")
->assertUnauthorized();
$this->postJson("/api/schools/{$school}/attendance/scans", [
'student_number' => 'GUEST-1001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
])->assertUnauthorized();
$this->postJson("/api/schools/{$school}/communications/preview", [
'template_key' => 'attendance_absence',
'student_id' => $student,
'channel' => 'email',
])->assertUnauthorized();
$this->postJson("/api/schools/{$school}/communications/send", [
'template_key' => 'attendance_absence',
'student_id' => $student,
'channel' => 'email',
])->assertUnauthorized();
$file = UploadedFile::fake()->createWithContent(
'payments.csv',
"student_number,amount,paid_at,reference\nGUEST-1001,125.00,2026-05-29,PAY-GUEST-001\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $file,
])->assertUnauthorized();
$this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29")
->assertUnauthorized();
}
private function createSchool(string $name = 'Test School', ?string $code = null): int
{
return (int) DB::table('schools')->insertGetId([
'name' => $name,
'code' => $code ?? 'SCH-'.uniqid(),
'domain_profile' => 'standard_school',
'timezone' => 'UTC',
'locale' => 'en',
'currency' => 'USD',
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createStudent(int $school, string $studentNumber, string $firstName = 'Maya'): int
{
return (int) DB::table('students')->insertGetId([
'school_id' => $school,
'school_id_number' => $studentNumber,
'student_identifier' => $studentNumber,
'first_name' => $firstName,
'last_name' => 'Patel',
'status' => 'active',
'profile_metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR),
'metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createTemplate(int $school, string $key, string $channel, string $body): int
{
return (int) DB::table('communication_templates')->insertGetId([
'school_id' => $school,
'template_key' => $key,
'version' => '1',
'status' => 'active',
'channel' => $channel,
'message_category' => 'general',
'language' => 'en',
'subject' => 'Notice for {{ student.first_name }}',
'body' => $body,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createActorForSchool(int $school): Authenticatable
{
$this->ensureAuthTestTablesExist();
$userId = (int) DB::table('users')->insertGetId([
'name' => 'Test User '.$school,
'email' => 'user'.$school.'@example.test',
'password' => bcrypt('password'),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('school_user')->insert([
'school_id' => $school,
'user_id' => $userId,
'role' => 'admin',
'created_at' => now(),
'updated_at' => now(),
]);
return new GenericUser([
'id' => $userId,
'name' => 'Test User '.$school,
'email' => 'user'.$school.'@example.test',
]);
}
private function ensureAuthTestTablesExist(): void
{
if (! Schema::hasTable('users')) {
Schema::create('users', function ($table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
if (! Schema::hasTable('school_user')) {
Schema::create('school_user', function ($table): void {
$table->id();
$table->unsignedBigInteger('school_id')->index();
$table->unsignedBigInteger('user_id')->index();
$table->string('role')->default('admin');
$table->timestamps();
$table->unique(['school_id', 'user_id']);
});
}
}
private function assertTenantBlocked($response): void
{
$this->assertContains(
$response->getStatusCode(),
[403, 404],
'Cross-tenant access must return either 403 or 404.'
);
}
}
@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Integration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Auth\GenericUser;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
final class SchoolWorkflowIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_school_can_be_created_and_read_through_http(): void
{
$response = $this->postJson('/api/schools', [
'name' => 'North Ridge School',
'code' => 'NRS',
'timezone' => 'America/New_York',
]);
$response->assertCreated()
->assertJsonPath('data.name', 'North Ridge School')
->assertJsonPath('data.code', 'NRS');
$schoolId = (int) $response->json('data.id');
$this->actingAsSchoolUser($schoolId);
$this->getJson("/api/schools/{$schoolId}")
->assertOk()
->assertJsonPath('data.timezone', 'America/New_York');
$this->assertDatabaseHas('schools', [
'id' => $schoolId,
'code' => 'NRS',
]);
}
public function test_student_can_be_created_for_school_and_isolated_by_school(): void
{
$schoolA = $this->createSchool('School A', 'S-A');
$schoolB = $this->createSchool('School B', 'S-B');
$response = $this->postJson("/api/schools/{$schoolA}/students", [
'first_name' => 'Maya',
'last_name' => 'Patel',
'student_number' => 'STU-1001',
'grade_level' => '7',
]);
$response->assertCreated()
->assertJsonPath('data.first_name', 'Maya')
->assertJsonPath('data.student_number', 'STU-1001');
$studentId = $response->json('data.id');
$this->getJson("/api/schools/{$schoolA}/students/{$studentId}")
->assertOk()
->assertJsonPath('data.grade_level', '7');
$this->getJson("/api/schools/{$schoolB}/students/{$studentId}")
->assertNotFound();
}
public function test_attendance_scan_records_once_and_duplicate_scan_is_idempotent(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-2001');
$payload = [
'student_number' => 'STU-2001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
];
$this->postJson("/api/schools/{$school}/attendance/scans", $payload)
->assertCreated()
->assertJsonPath('data.status', 'recorded');
$this->postJson("/api/schools/{$school}/attendance/scans", $payload)
->assertOk()
->assertJsonPath('data.status', 'duplicate');
$this->assertDatabaseHas('attendance_records', [
'school_id' => $school,
'subject_type' => 'student',
'subject_id' => $student,
'status' => 'present',
]);
$this->assertSame(1, DB::table('attendance_records')->where('school_id', $school)->where('subject_id', $student)->count());
$this->assertSame(2, DB::table('attendance_scan_logs')->where('school_id', $school)->where('subject_id', $student)->count());
}
public function test_communication_preview_has_no_side_effect_and_send_queues_recipient(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-3001', 'Maya');
$this->createTemplate($school, 'attendance_absence', 'email', 'Hello, {{ student.first_name }} was marked absent.');
$this->postJson("/api/schools/{$school}/communications/preview", [
'template_key' => 'attendance_absence',
'student_id' => $student,
'channel' => 'email',
])->assertOk()
->assertJsonPath('data.body', 'Hello, Maya was marked absent.');
$this->assertDatabaseCount('communication_messages', 0);
$this->assertDatabaseCount('communication_message_recipients', 0);
$this->postJson("/api/schools/{$school}/communications/send", [
'template_key' => 'attendance_absence',
'student_id' => $student,
'channel' => 'email',
])->assertAccepted()
->assertJsonPath('data.status', 'queued');
$this->assertDatabaseHas('communication_messages', [
'school_id' => $school,
'channel' => 'email',
'status' => 'queued',
]);
$this->assertDatabaseHas('communication_message_recipients', [
'school_id' => $school,
'recipient_type' => 'student',
'recipient_id' => $student,
'status' => 'queued',
]);
}
public function test_payment_file_imports_valid_rows_and_rejects_invalid_rows_without_partial_import(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-4001');
$validFile = UploadedFile::fake()->createWithContent(
'payments.csv',
"student_number,amount,paid_at,reference\nSTU-4001,125.00,2026-05-29,PAY-001\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $validFile,
])->assertCreated()
->assertJsonPath('data.imported_count', 1);
$this->assertDatabaseHas('payments', [
'school_id' => $school,
'student_id' => $student,
'amount_minor' => 12500,
'reference_number' => 'PAY-001',
]);
$invalidFile = UploadedFile::fake()->createWithContent(
'bad-payments.csv',
"student_number,amount,paid_at,reference\nSTU-4001,80.00,2026-05-29,PAY-002\nMISSING,90.00,2026-05-29,PAY-003\n"
);
$this->postJson("/api/schools/{$school}/finance/payment-files", [
'file' => $invalidFile,
])->assertUnprocessable();
$this->assertDatabaseMissing('payments', [
'school_id' => $school,
'reference_number' => 'PAY-002',
]);
$this->assertDatabaseMissing('payments', [
'school_id' => $school,
'reference_number' => 'PAY-003',
]);
}
public function test_daily_report_aggregates_attendance_and_finance(): void
{
$school = $this->createSchool();
$this->actingAsSchoolUser($school);
$student = $this->createStudent($school, 'STU-5001');
$this->postJson("/api/schools/{$school}/attendance/scans", [
'student_number' => 'STU-5001',
'scanned_at' => '2026-05-29T08:15:00Z',
'source' => 'kiosk',
])->assertCreated();
DB::table('payments')->insert([
'school_id' => $school,
'student_id' => $student,
'amount_minor' => 12500,
'currency' => 'USD',
'payment_method' => 'manual',
'payment_date' => '2026-05-29',
'reference_number' => 'PAY-RPT-001',
'status' => 'recorded',
'created_at' => now(),
'updated_at' => now(),
]);
$this->getJson("/api/schools/{$school}/reports/daily?date=2026-05-29")
->assertOk()
->assertJsonPath('data.attendance.present_count', 1)
->assertJsonPath('data.finance.total_payments', 12500);
}
private function createSchool(string $name = 'Test School', ?string $code = null): int
{
return (int) DB::table('schools')->insertGetId([
'name' => $name,
'code' => $code ?? 'SCH-'.uniqid(),
'domain_profile' => 'standard_school',
'timezone' => 'UTC',
'locale' => 'en',
'currency' => 'USD',
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createStudent(int $school, string $studentNumber, string $firstName = 'Maya'): int
{
return (int) DB::table('students')->insertGetId([
'school_id' => $school,
'school_id_number' => $studentNumber,
'student_identifier' => $studentNumber,
'first_name' => $firstName,
'last_name' => 'Patel',
'status' => 'active',
'profile_metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR),
'metadata' => json_encode(['grade_level' => '7'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
}
private function createTemplate(int $school, string $key, string $channel, string $body): int
{
return (int) DB::table('communication_templates')->insertGetId([
'school_id' => $school,
'template_key' => $key,
'version' => '1',
'status' => 'active',
'channel' => $channel,
'message_category' => 'general',
'language' => 'en',
'subject' => 'Notice for {{ student.first_name }}',
'body' => $body,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function actingAsSchoolUser(int $school): Authenticatable
{
$this->ensureAuthTestTablesExist();
$userId = (int) DB::table('users')->insertGetId([
'name' => 'Workflow User '.$school,
'email' => 'workflow'.$school.'@example.test',
'password' => bcrypt('password'),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('school_user')->insert([
'school_id' => $school,
'user_id' => $userId,
'role' => 'admin',
'created_at' => now(),
'updated_at' => now(),
]);
$user = new GenericUser([
'id' => $userId,
'name' => 'Workflow User '.$school,
'email' => 'workflow'.$school.'@example.test',
]);
$this->actingAs($user);
return $user;
}
private function ensureAuthTestTablesExist(): void
{
if (! Schema::hasTable('users')) {
Schema::create('users', function ($table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
if (! Schema::hasTable('school_user')) {
Schema::create('school_user', function ($table): void {
$table->id();
$table->unsignedBigInteger('school_id')->index();
$table->unsignedBigInteger('user_id')->index();
$table->string('role')->default('admin');
$table->timestamps();
$table->unique(['school_id', 'user_id']);
});
}
}
}
@@ -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,18 @@
<?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__, 4).'/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,22 @@
<?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
{
$routeFile = dirname(__DIR__, 4).'/routes/finance_phase3.php';
$this->assertFileExists($routeFile);
$routes = file_get_contents($routeFile);
$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,17 @@
<?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__, 4);
$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,17 @@
<?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
{
$routeFile = dirname(__DIR__, 4).'/routes/student_lifecycle_phase5.php';
$this->assertFileExists($routeFile);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}
@@ -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 Tests\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'); } }