serviceExpectingClassLookup('9', ['id' => 9, 'class_name' => '9']); $result = $this->invoke($service, 'nextClass', ['Class 8', '2026-2027']); $this->assertSame(9, (int) $result['id']); } public function testPassedClassNinePromotesToTenWhenGradeTenExists(): void { $service = $this->serviceExpectingClassLookup('10', ['id' => 10, 'class_name' => '10']); $result = $this->invoke($service, 'nextClass', ['Class 9', '2026-2027']); $this->assertSame(10, (int) $result['id']); } public function testKgMissingDecisionUsesAgePlacementDecision(): void { $service = new EnrollmentTransitionService($this->createMock(BaseConnection::class)); $this->assertSame( DeliberationDecision::PASSED, $this->invoke($service, 'decisionForKgWithoutFinalDecision', [['class_name' => 'KG'], 6, '']) ); $this->assertSame( DeliberationDecision::REPEAT_CLASS, $this->invoke($service, 'decisionForKgWithoutFinalDecision', [['class_name' => 'Kindergarten'], 5, '']) ); $this->assertNull($this->invoke($service, 'decisionForKgWithoutFinalDecision', [['class_name' => 'KG'], 6, 'Needs review'])); $this->assertNull($this->invoke($service, 'decisionForKgWithoutFinalDecision', [['class_name' => '1'], 6, ''])); } public function testExplicitNewStudentStatusMarksFirstEnrollmentCandidate(): void { $statusBuilder = $this->builderReturning(['is_new' => 1]); $statusBuilder->method('where')->willReturnSelf(); $db = $this->createMock(BaseConnection::class); $db->method('tableExists')->with('student_year_status')->willReturn(true); $db->method('table')->with('student_year_status')->willReturn($statusBuilder); $service = new EnrollmentTransitionService($db); $this->assertTrue($this->invoke($service, 'isFirstEnrollmentStudent', [ 42, ['school_year' => '2026-2027'], '2026-2027', ])); } public function testFirstEnrollmentPlacementKeepsRegistrationGradeAsReferenceOnly(): void { $db = $this->createMock(BaseConnection::class); $db->expects($this->never())->method('table'); $service = new EnrollmentTransitionService($db); $placement = $this->invoke($service, 'firstEnrollmentPlacement', [ ['registration_grade' => 'Grade 3'], '2026-2027', ]); $this->assertNull($placement['assigned_grade_id']); $this->assertSame('3', $placement['assigned_grade_name']); $this->assertNull($placement['assigned_class_section_id']); $this->assertSame('manual_class_required', $placement['placement_status']); } public function testClassLookupFallsBackToGlobalRowsWhenSchoolYearSpecificRowIsMissing(): void { $firstBuilder = $this->builderReturning(null); $firstBuilder->expects($this->exactly(2)) ->method('where') ->willReturnSelf(); $fallbackBuilder = $this->builderReturning(['id' => 10, 'class_name' => '10']); $fallbackBuilder->expects($this->once()) ->method('where') ->with('UPPER(class_name)', '10') ->willReturnSelf(); $db = $this->createMock(BaseConnection::class); $db->expects($this->exactly(2)) ->method('table') ->with('classes') ->willReturnOnConsecutiveCalls($firstBuilder, $fallbackBuilder); $db->method('fieldExists')->with('school_year', 'classes')->willReturn(true); $service = new EnrollmentTransitionService($db); $result = $this->invoke($service, 'nextClass', ['Class 9', '2026-2027']); $this->assertSame(10, (int) $result['id']); } private function serviceExpectingClassLookup(string $expectedClassName, array $row): EnrollmentTransitionService { $builder = $this->builderReturning($row); $builder->expects($this->once()) ->method('where') ->with('UPPER(class_name)', $expectedClassName) ->willReturnSelf(); $db = $this->createMock(BaseConnection::class); $db->expects($this->once()) ->method('table') ->with('classes') ->willReturn($builder); $db->method('fieldExists')->with('school_year', 'classes')->willReturn(false); return new EnrollmentTransitionService($db); } private function builderReturning(?array $row): BaseBuilder { $builder = $this->createMock(BaseBuilder::class); $builder->method('select')->willReturnSelf(); $builder->method('orderBy')->willReturnSelf(); $builder->method('limit')->willReturnSelf(); $builder->method('get')->willReturn(new class($row) { public function __construct(private readonly ?array $row) { } public function getRowArray(): ?array { return $this->row; } public function getResultArray(): array { return $this->row === null ? [] : [$this->row]; } }); return $builder; } public function testNormalizeLastNameIgnoresPunctuationAndCase(): void { $service = new EnrollmentTransitionService($this->createMock(BaseConnection::class)); $this->assertSame('omalley', $this->invoke($service, 'normalizeLastName', ["O'Malley"])); $this->assertSame('omalley', $this->invoke($service, 'normalizeLastName', ['O-Malley'])); } public function testSingleLinkedStudentPassesLastNameRule(): void { $service = $this->serviceWithStudentRows([ ['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9], ]); $evaluation = []; $this->invoke($service, 'applyHouseholdLastNameRule', [&$evaluation, 9]); $this->assertTrue($evaluation['family_name_check_ok']); $this->assertArrayNotHasKey('blocking_rule_codes', $evaluation); } public function testMismatchedSiblingLastNamesBlockEveryStudent(): void { $service = $this->serviceWithStudentRows([ ['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9], ['id' => 2, 'firstname' => 'Omar', 'lastname' => 'Ali', 'parent_id' => 9], ]); $evaluation = []; $this->invoke($service, 'applyHouseholdLastNameRule', [&$evaluation, 9]); $this->assertFalse($evaluation['family_name_check_ok']); $this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']); } public function testMissingSiblingLastNameBlocksFamily(): void { $service = $this->serviceWithStudentRows([ ['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9], ['id' => 2, 'firstname' => 'Omar', 'lastname' => ' ', 'parent_id' => 9], ]); $evaluation = []; $this->invoke($service, 'applyHouseholdLastNameRule', [&$evaluation, 9]); $this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']); } public function testNewStudentDoesNotMakeReturningSiblingsFailLastNameRule(): void { $studentBuilder = $this->createMock(BaseBuilder::class); $studentBuilder->method('select')->willReturnSelf(); $studentBuilder->method('where')->willReturnSelf(); $studentBuilder->method('orderBy')->willReturnSelf(); $studentBuilder->method('get')->willReturn(new class { public function getResultArray(): array { return [ ['id' => 1, 'firstname' => 'Ibrahim', 'lastname' => 'Khalid', 'parent_id' => 9], ['id' => 2, 'firstname' => 'Eesa', 'lastname' => 'Khalid', 'parent_id' => 9], ['id' => 3, 'firstname' => 'Lamia', 'lastname' => 'Khalid', 'parent_id' => 9], ['id' => 4, 'firstname' => 'New', 'lastname' => 'Khalid', 'parent_id' => 9], ]; } }); $assignmentBuilder = $this->createMock(BaseBuilder::class); $assignmentBuilder->method('select')->willReturnSelf(); $assignmentBuilder->method('join')->willReturnSelf(); $assignmentBuilder->method('where')->willReturnSelf(); $assignmentBuilder->method('orderBy')->willReturnSelf(); $assignmentBuilder->method('limit')->willReturnSelf(); $assignmentBuilder->method('get')->willReturnCallback(new class { private int $calls = 0; public function __invoke(): object { $this->calls++; $hasSourceAssignment = $this->calls <= 3; return new class($hasSourceAssignment) { public function __construct(private readonly bool $hasSourceAssignment) { } public function getRowArray(): ?array { return $this->hasSourceAssignment ? ['class_section_id' => 10] : null; } }; } }); $db = $this->createMock(BaseConnection::class); $db->method('tableExists')->willReturnCallback(static fn (string $table): bool => in_array($table, ['students', 'student_class'], true)); $db->method('table')->willReturnCallback(static function (string $table) use ($studentBuilder, $assignmentBuilder) { return $table === 'students' ? $studentBuilder : $assignmentBuilder; }); $service = new EnrollmentTransitionService($db); $evaluation = []; $this->invoke($service, 'applyHouseholdLastNameRule', [&$evaluation, 9, '2025-2026']); $this->assertTrue($evaluation['family_name_check_ok']); $this->assertArrayNotHasKey('blocking_rule_codes', $evaluation); } public function testPreviousYearBalanceBlocksEnrollment(): void { $service = $this->serviceWithFinance(125.50, 'submission_blocked_until_payment'); $evaluation = []; $this->invoke($service, 'applyFinancialRule', [&$evaluation, 9, '2025-2026', '2026-2027']); $this->assertContains('OUTSTANDING_BALANCE_BLOCKED', $evaluation['blocking_rule_codes']); $this->assertSame(125.50, $evaluation['financial_summary']['carry_over_balance']); } public function testPreviousYearBalanceWithAdminApprovalPolicyUsesFinanceReviewCode(): void { $service = $this->serviceWithFinance(40.00, 'admin_approval_required'); $evaluation = []; $this->invoke($service, 'applyFinancialRule', [&$evaluation, 9, '2025-2026', '2026-2027']); $this->assertContains('FINANCE_APPROVAL_REQUIRED', $evaluation['blocking_rule_codes']); } public function testZeroCarryOverBalanceDoesNotBlock(): void { $service = $this->serviceWithFinance(0.00, 'submission_blocked_until_payment'); $evaluation = []; $this->invoke($service, 'applyFinancialRule', [&$evaluation, 9, '2025-2026', '2026-2027']); $this->assertArrayNotHasKey('blocking_rule_codes', $evaluation); } public function testActiveEnrollmentStatusesBlockDuplicates(): void { $service = new EnrollmentTransitionService($this->createMock(BaseConnection::class)); $this->assertTrue($this->invoke($service, 'activeEnrollmentBlocksDuplicate', [[ 'is_withdrawn' => 0, 'enrollment_status' => 'payment pending', 'admission_status' => 'pending', ]])); $this->assertFalse($this->invoke($service, 'activeEnrollmentBlocksDuplicate', [[ 'is_withdrawn' => 1, 'enrollment_status' => 'withdrawn', 'admission_status' => 'accepted', ]])); } public function testTargetEnrollmentReviewCodesIncludeWithdrawnRows(): void { $builder = $this->createMock(BaseBuilder::class); $builder->method('select')->willReturnSelf(); $builder->method('where')->willReturnSelf(); $builder->method('orderBy')->willReturnSelf(); $builder->method('get')->willReturn(new class { public function getResultArray(): array { return [[ 'student_id' => 42, 'enrollment_status' => 'payment pending', 'admission_status' => 'accepted', 'is_withdrawn' => 1, ]]; } }); $db = $this->createMock(BaseConnection::class); $db->method('tableExists')->with('enrollments')->willReturn(true); $db->method('fieldExists')->with('is_withdrawn', 'enrollments')->willReturn(true); $db->method('table')->with('enrollments')->willReturn($builder); $service = new EnrollmentTransitionService($db); $reviewCodes = $this->invoke($service, 'targetEnrollmentReviewCodesByStudent', ['2026-2027']); $this->assertSame('WITHDRAWN', $reviewCodes[42]['rule_code']); } /** * @param list> $rows */ private function serviceWithStudentRows(array $rows): EnrollmentTransitionService { $builder = $this->createMock(BaseBuilder::class); $builder->method('select')->willReturnSelf(); $builder->method('where')->willReturnSelf(); $builder->method('orderBy')->willReturnSelf(); $builder->method('get')->willReturn(new class($rows) { public function __construct(private readonly array $rows) { } public function getResultArray(): array { return $this->rows; } }); $db = $this->createMock(BaseConnection::class); $db->method('tableExists')->with('students')->willReturn(true); $db->method('table')->with('students')->willReturn($builder); return new EnrollmentTransitionService($db); } private function serviceWithFinance(float $balance, string $behavior): EnrollmentTransitionService { $yearBuilder = $this->builderReturning([ 'name' => '2026-2027', 'carry_over_balance_behavior' => $behavior, 'registration_fee' => 0, 'tuition_due_at_registration' => 0, 'mandatory_fees' => 0, ]); $yearBuilder->method('where')->willReturnSelf(); $invoiceBuilder = $this->builderReturning(['balance' => $balance]); $invoiceBuilder->method('where')->willReturnSelf(); $db = $this->createMock(BaseConnection::class); $db->method('tableExists')->willReturn(true); $db->method('table')->willReturnCallback(static function (string $table) use ($yearBuilder, $invoiceBuilder) { return $table === 'school_years' ? $yearBuilder : $invoiceBuilder; }); return new EnrollmentTransitionService($db); } public function testPreviousLastNameExceptionCarriesForwardWhenHouseholdUnchangedAndBalancePaid(): void { $service = $this->serviceWithLastNameCarryForward( [ ['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9], ['id' => 2, 'firstname' => 'Omar', 'lastname' => 'Ali', 'parent_id' => 9], ], [[ 'id' => 44, 'parent_id' => 9, 'student_id' => 1, 'school_year' => '2025-2026', 'status' => 'used', 'reason_code' => 'SIBLING_LAST_NAME_REVIEWED', 'bypassed_rule_codes_json' => json_encode(['SIBLING_LAST_NAME_MISMATCH']), 'family_student_ids_json' => json_encode([1, 2]), ]] ); $evaluation = [ 'blocking_rule_codes' => ['SIBLING_LAST_NAME_MISMATCH'], 'blockers' => ['Linked siblings have different last names. Please contact administration to review the family record.'], 'warnings' => [], ]; $this->invoke($service, 'applyCarriedForwardLastNameException', [&$evaluation, 9, 1, '2025-2026', '2026-2027']); $this->assertNotContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']); $this->assertContains('LAST_NAME_EXCEPTION_CARRIED_FORWARD', $evaluation['warning_rule_codes']); $this->assertTrue($evaluation['last_name_exception_carry_forward']['eligible']); } public function testEmptyBypassCodesDoNotGrantException(): void { $builder = $this->createMock(BaseBuilder::class); $builder->method('where')->willReturnSelf(); $builder->method('groupStart')->willReturnSelf(); $builder->method('orWhere')->willReturnSelf(); $builder->method('groupEnd')->willReturnSelf(); $builder->method('whereIn')->willReturnSelf(); $builder->method('orderBy')->willReturnSelf(); $builder->method('limit')->willReturnSelf(); $builder->method('get')->willReturn(new class { public function getRowArray(): array { return [ 'id' => 7, 'reason_code' => 'FINANCIAL_REVIEW_REQUIRED', 'bypassed_rule_codes_json' => '[]', ]; } public function getResultArray(): array { return [$this->getRowArray()]; } }); $db = $this->createMock(BaseConnection::class); $db->method('tableExists')->with('enrollment_exceptions')->willReturn(true); $db->method('table')->with('enrollment_exceptions')->willReturn($builder); $service = new EnrollmentTransitionService($db); $evaluation = [ 'blocking_rule_codes' => ['OUTSTANDING_BALANCE_BLOCKED'], 'review_rule_codes' => [], ]; $this->invoke($service, 'applyScopedException', [&$evaluation, 9, 1, '2025-2026', '2026-2027']); $this->assertArrayNotHasKey('admin_exception', $evaluation); $this->assertArrayNotHasKey('parent_enrollment_allowed', $evaluation); } public function testClassReassignmentFlagIsIgnored(): void { $db = $this->createMock(BaseConnection::class); $db->method('tableExists')->with('enrollment_flags')->willReturn(true); $db->expects($this->never())->method('table'); $service = new EnrollmentTransitionService($db); $written = $this->invoke($service, 'upsertOpenFlag', [ 123, '2026-2027', '2025-2026', [ 'flag_type' => 'CLASS_REASSIGNMENT_REQUIRED', 'priority' => 'normal', 'details' => ['reason' => 'repeat class'], ], null, ]); $this->assertFalse($written); } public function testPreviousLastNameExceptionDoesNotCarryForwardWhenNewStudentIsAdded(): void { $service = $this->serviceWithLastNameCarryForward( [ ['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9], ['id' => 2, 'firstname' => 'Omar', 'lastname' => 'Ali', 'parent_id' => 9], ['id' => 3, 'firstname' => 'Layla', 'lastname' => 'Hassan', 'parent_id' => 9], ], [[ 'id' => 44, 'parent_id' => 9, 'student_id' => 1, 'school_year' => '2025-2026', 'status' => 'used', 'reason_code' => 'SIBLING_LAST_NAME_REVIEWED', 'bypassed_rule_codes_json' => json_encode(['SIBLING_LAST_NAME_MISMATCH']), 'family_student_ids_json' => json_encode([1, 2]), ]] ); $evaluation = [ 'blocking_rule_codes' => ['SIBLING_LAST_NAME_MISMATCH'], 'blockers' => ['Linked siblings have different last names. Please contact administration to review the family record.'], ]; $this->invoke($service, 'applyCarriedForwardLastNameException', [&$evaluation, 9, 1, '2025-2026', '2026-2027']); $this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']); $this->assertFalse($evaluation['last_name_exception_carry_forward']['eligible']); $this->assertSame('NEW_STUDENT_ADDED', $evaluation['last_name_exception_carry_forward']['reason']); } public function testPreviousLastNameExceptionDoesNotCarryForwardWhenBalanceIsUnpaid(): void { $service = new EnrollmentTransitionService($this->createMock(BaseConnection::class)); $evaluation = [ 'blocking_rule_codes' => ['SIBLING_LAST_NAME_MISMATCH', 'OUTSTANDING_BALANCE_BLOCKED'], ]; $this->invoke($service, 'applyCarriedForwardLastNameException', [&$evaluation, 9, 1, '2025-2026', '2026-2027']); $this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']); $this->assertArrayNotHasKey('last_name_exception_carry_forward', $evaluation); } /** * @param list> $students * @param list> $exceptions */ private function serviceWithLastNameCarryForward(array $students, array $exceptions): EnrollmentTransitionService { $studentBuilder = $this->createMock(BaseBuilder::class); $studentBuilder->method('select')->willReturnSelf(); $studentBuilder->method('where')->willReturnSelf(); $studentBuilder->method('orderBy')->willReturnSelf(); $studentBuilder->method('get')->willReturn(new class($students) { public function __construct(private readonly array $rows) { } public function getResultArray(): array { return $this->rows; } }); $exceptionBuilder = $this->createMock(BaseBuilder::class); $exceptionBuilder->method('where')->willReturnSelf(); $exceptionBuilder->method('whereIn')->willReturnSelf(); $exceptionBuilder->method('orderBy')->willReturnSelf(); $exceptionBuilder->method('get')->willReturn(new class($exceptions) { public function __construct(private readonly array $rows) { } public function getResultArray(): array { return $this->rows; } }); $db = $this->createMock(BaseConnection::class); $db->method('tableExists')->willReturn(true); $db->method('table')->willReturnCallback(static function (string $table) use ($studentBuilder, $exceptionBuilder) { return $table === 'students' ? $studentBuilder : $exceptionBuilder; }); return new EnrollmentTransitionService($db); } /** * @param list $args */ private function invoke(EnrollmentTransitionService $service, string $method, array $args): mixed { $reflection = new \ReflectionMethod($service, $method); $reflection->setAccessible(true); return $reflection->invokeArgs($service, $args); } }