service(); $evaluation = [ 'deliberation_decision' => DeliberationDecision::PASSED, 'placement_status' => 'exit_required', 'source_grade_name' => '8', 'adult_student' => false, 'blockers' => [ 'The student has passed the highest available grade and must follow the school completion or exit process.', 'Registration for the new school year has not opened yet.', ], ]; $status = $this->invoke($service, 'registrationStatus', [$evaluation]); $message = $this->invoke($service, 'decisionMessage', ['Student Name', $evaluation, 'August 1, 2026', 'August 31, 2026']); $action = $this->invoke($service, 'requiredAction', [$evaluation, 'August 31, 2026']); $this->assertSame('Eligible', $status); $this->assertStringContainsString('Student Name has successfully passed Grade 8.', $message); $this->assertStringNotContainsString('Re-enrollment for the new school year will open on August 1, 2026.', $message); $this->assertStringNotContainsString('Please make sure to re-enroll your child before August 31, 2026', $message); $this->assertStringNotContainsString('sign in to the parent portal', $message); $this->assertStringContainsString('Complete re-enrollment before August 31, 2026.', $action); $this->assertStringNotContainsString('Not Eligible', $status); $this->assertStringNotContainsString('Registration for the new school year has not opened yet', $message); $this->assertStringNotContainsString('Contact the school administration.', $action); } public function testFinancialSectionIsHiddenWhenNothingIsDue(): void { $service = $this->service(); $html = $this->invoke($service, 'financialSection', [10, [ 'registration_fee' => 0, 'tuition_due_at_registration' => 0, 'mandatory_fees' => 0, ], null]); $this->assertSame('', $html); } public function testMakeupExamDecisionMessageUsesConfiguredDate(): void { $configModel = $this->createMock(ConfigurationModel::class); $configModel->expects($this->once()) ->method('getConfig') ->with('make-up-exam') ->willReturn('2026-09-13'); $service = $this->service(configModel: $configModel); $message = $this->invoke($service, 'decisionMessage', ['Student Name', [ 'deliberation_decision' => DeliberationDecision::MAKE_UP_EXAM, 'blockers' => [], ], 'August 1, 2026', 'August 31, 2026']); $this->assertStringContainsString('The final academic decision for Student Name is currently pending the result of a make-up exam.', $message); $this->assertStringContainsString('This exam is scheduled for 09-13-2026 from 9:30 AM to 11:00 AM at ISGL.', $message); $this->assertStringContainsString("\n" . 'The exam result will determine whether the student advances to the next grade or repeats the current class. Failure to attend the make-up exam will automatically result in the student repeating the class, as no further retake opportunities will be available.', $message); $this->assertStringContainsString("\n" . 'You must re-enroll Student Name before the make-up exam can be taken.', $message); $this->assertStringNotContainsString('on date at ISGL', $message); $action = $this->invoke($service, 'requiredAction', [[ 'deliberation_decision' => DeliberationDecision::MAKE_UP_EXAM, 'blockers' => [], ], 'August 31, 2026', 'Student Name']); $this->assertSame('', $action); } public function testKgMissingDecisionEligibleMessageAllowsRegistration(): void { $service = $this->service(); $message = $this->invoke($service, 'decisionMessage', ['Student Name', [ 'can_enroll' => false, 'kg_missing_decision_eligible' => true, 'deliberation_decision' => DeliberationDecision::PASSED, 'placement_status' => 'automatic_distribution_pending', 'assigned_grade_name' => '1', 'blocking_rule_codes' => ['NO_FINAL_DECISION'], 'blockers' => ['Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.'], ], 'August 1, 2026', 'August 31, 2026']); $status = $this->invoke($service, 'registrationStatus', [[ 'can_enroll' => false, 'kg_missing_decision_eligible' => true, 'deliberation_decision' => DeliberationDecision::PASSED, 'blocking_rule_codes' => ['NO_FINAL_DECISION'], 'blockers' => ['Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.'], ]]); $this->assertSame('Eligible', $status); $this->assertStringContainsString('KG students may complete registration now.', $message); $this->assertStringContainsString('Student Name is eligible for re-enrollment even though no final deliberation decision is recorded.', $message); $this->assertStringContainsString('Grade 1', $message); $this->assertStringNotContainsString('cannot currently be completed', $message); } public function testAlreadyEnrolledStatusDoesNotRenderAsNotEligible(): void { $service = $this->service(); $status = $this->invoke($service, 'registrationStatus', [[ 'can_enroll' => false, 'deliberation_decision' => DeliberationDecision::PASSED, 'decision' => 'ALREADY_ENROLLED', 'primary_block_reason' => 'ALREADY_ENROLLED', 'blocking_rule_codes' => ['ALREADY_ENROLLED'], 'blockers' => ['This student is already enrolled for the selected school year.'], 'enrollment_status' => 'enrolled', ]]); $this->assertSame('Already Enrolled', $status); } public function testMakeupExamDecisionMessageUsesSchoolYearDateBeforeConfig(): void { $configModel = $this->createMock(ConfigurationModel::class); $configModel->expects($this->never())->method('getConfig'); $service = $this->service(configModel: $configModel); $message = $this->invoke($service, 'decisionMessage', ['Student Name', [ 'deliberation_decision' => DeliberationDecision::MAKE_UP_EXAM, 'blockers' => [], ], 'August 1, 2026', 'August 31, 2026', [ 'fall_makeup_exam_on' => '2026-09-13', ]]); $this->assertStringContainsString('This exam is scheduled for 09-13-2026 from 9:30 AM to 11:00 AM at ISGL.', $message); $this->assertStringNotContainsString('a date to be announced', $message); } public function testMakeupExamDecisionMessageFallsBackToSyncedConfigKeys(): void { $configModel = $this->createMock(ConfigurationModel::class); $configModel->expects($this->exactly(2)) ->method('getConfig') ->willReturnMap([ ['make-up-exam', ''], ['make_up_exam', '2026-09-13'], ]); $service = $this->service(configModel: $configModel); $message = $this->invoke($service, 'decisionMessage', ['Student Name', [ 'deliberation_decision' => DeliberationDecision::MAKE_UP_EXAM, 'blockers' => [], ], 'August 1, 2026', 'August 31, 2026']); $this->assertStringContainsString('This exam is scheduled for 09-13-2026 from 9:30 AM to 11:00 AM at ISGL.', $message); } public function testMakeupExamStudentRowRendersMessageLineBreaks(): void { $configModel = $this->createMock(ConfigurationModel::class); $configModel->method('getConfig') ->with('make-up-exam') ->willReturn('2026-09-13'); $service = $this->service(configModel: $configModel); $html = $this->invoke($service, 'studentRow', [[ 'firstname' => 'Student', 'lastname' => 'Name', ], [ 'deliberation_decision' => DeliberationDecision::MAKE_UP_EXAM, 'placement_status' => 'temporary_same_grade', 'blockers' => [], ], 'August 1, 2026', 'August 31, 2026']); $this->assertStringContainsString('from 9:30 AM to 11:00 AM at ISGL.
', $html); $this->assertStringContainsString('available.
', $html); $this->assertStringContainsString('You must re-enroll Student Name before the make-up exam can be taken.', $html); $this->assertStringNotContainsString('before they are permitted', $html); } public function testFinancialSectionShowsWhenAnyAmountIsDue(): void { $service = $this->service(); $html = $this->invoke($service, 'financialSection', [10, [ 'registration_fee' => 25, 'tuition_due_at_registration' => 10, 'mandatory_fees' => 5, ], null]); $this->assertStringContainsString('Family Account Information', $html); $this->assertStringContainsString('Carry-over balance: $0.00', $html); $this->assertStringContainsString('Total currently due: $40.00', $html); $this->assertStringNotContainsString('Registration fee:', $html); $this->assertStringNotContainsString('New-year tuition due now:', $html); $this->assertStringNotContainsString('Mandatory fees:', $html); } public function testAutomaticDistributionPlacementShowsOnlyGrade(): void { $service = $this->service(); $placement = $this->invoke($service, 'placementText', [[ 'placement_status' => 'automatic_distribution_pending', 'assigned_grade_name' => '9', ]]); $this->assertSame('Grade 9', $placement); } public function testStudentSummaryUsesStackedEmailRows(): void { $service = $this->service(); $html = $this->invoke($service, 'studentRow', [[ 'firstname' => 'Test', 'lastname' => 'Student', ], [ 'deliberation_decision' => DeliberationDecision::PASSED, 'placement_status' => 'automatic_distribution_pending', 'source_grade_name' => '4', 'assigned_grade_name' => '5', 'blockers' => [], ], 'August 1, 2026', 'August 31, 2026']); $this->assertStringContainsString('role="presentation"', $html); $this->assertStringContainsString('Decision', $html); $this->assertStringContainsString('Registration Status', $html); $this->assertStringContainsString('Next Step', $html); $this->assertStringContainsString('word-break:break-word', $html); $this->assertStringNotContainsString('service(); $html = $this->invoke($service, 'studentsTable', [['
']]); $this->assertStringStartsWith('
', $html); $this->assertStringContainsString('
', $html); $this->assertStringEndsWith('
', $html); } public function testRegistrationStepsSectionIsHiddenWhenNoStudentCanReEnroll(): void { $service = $this->service(); $html = $this->invoke($service, 'registrationStepsSection', ['August 1, 2026', 'August 31, 2026', false]); $this->assertSame('', $html); } public function testRegistrationStepsSectionShowsWhenAnyStudentCanReEnroll(): void { $service = $this->service(); $html = $this->invoke($service, 'registrationStepsSection', ['August 1, 2026', 'August 31, 2026', true]); $this->assertStringContainsString('Re-enrollment Steps', $html); $this->assertStringContainsString('Sign in to the parent portal.', $html); $this->assertStringContainsString('parent/enroll_classes', $html); } public function testStudentIssueCodeUsesFlagRuleCode(): void { $service = $this->service(); $code = $this->invoke($service, 'studentIssueCode', [[ 'flags' => [[ 'flag_type' => 'DEFERRED_DELIBERATION', 'details' => ['rule_code' => 'NO_FINAL_DECISION'], ]], ]]); $this->assertSame('NO_FINAL_DECISION', $code); } public function testStudentIssueCodeDoesNotColorNormalDecision(): void { $service = $this->service(); $code = $this->invoke($service, 'studentIssueCode', [[ 'can_enroll' => true, 'deliberation_decision' => DeliberationDecision::PASSED, 'flags' => [], ]]); $this->assertSame('', $code); } public function testParentReEnrollmentEligibilityUsesTransitionAllowedFlag(): void { $service = $this->service(); $this->assertTrue($this->invoke($service, 'canCompleteParentReEnrollment', [[ 'parent_enrollment_allowed' => true, 'blockers' => [], ]])); $this->assertFalse($this->invoke($service, 'canCompleteParentReEnrollment', [[ 'parent_enrollment_allowed' => false, 'blockers' => ['Contact administration.'], ]])); } public function testForceCannotSendWithoutAdminLaunchApproval(): void { $emailService = $this->createMock(EmailService::class); $emailService->expects($this->never())->method('send'); $service = $this->service($this->dbWithNoRecipientFamilies(), $emailService); $summary = $service->sendForSchoolYear([ 'name' => '2026-2027', 'registration_launch_approved_at' => null, ], null, false, true); $this->assertSame(0, $summary['sent']); $this->assertSame(1, $summary['skipped']); $this->assertStringContainsString('not approved', implode(' ', $summary['messages'])); } public function testFailedOnlyRetryUsesLatestFailedEmailRecordPerParent(): void { $query = new class { public function getResultArray(): array { return [ ['parent_user_id' => 10, 'delivery_status' => 'failed'], ['parent_user_id' => 10, 'delivery_status' => 'sent'], ['parent_user_id' => 20, 'delivery_status' => 'sent'], ['parent_user_id' => 20, 'delivery_status' => 'failed'], ['parent_user_id' => 30, 'delivery_status' => 'failed'], ['parent_user_id' => null, 'delivery_status' => 'failed'], ]; } }; $builder = $this->createMock(BaseBuilder::class); $builder->method('select')->willReturnSelf(); $builder->method('where')->with('school_year', '2026-2027')->willReturnSelf(); $builder->method('orderBy')->willReturnSelf(); $builder->method('get')->willReturn($query); $db = $this->createMock(BaseConnection::class); $db->method('tableExists')->with('enrollment_email_records')->willReturn(true); $db->method('table')->with('enrollment_email_records')->willReturn($builder); $service = $this->service($db); $this->assertSame([10, 30], $this->invoke($service, 'failedParentIdsForSchoolYear', ['2026-2027'])); } /** * @param list $args */ private function invoke(EnrollmentRegistrationEmailService $service, string $method, array $args): mixed { $reflection = new \ReflectionMethod($service, $method); $reflection->setAccessible(true); return $reflection->invokeArgs($service, $args); } private function service(?BaseConnection $db = null, ?EmailService $emailService = null, ?ConfigurationModel $configModel = null): EnrollmentRegistrationEmailService { $db ??= $this->createMock(BaseConnection::class); return new EnrollmentRegistrationEmailService( $db, new EnrollmentTransitionService($db), $emailService ?? $this->createMock(EmailService::class), $configModel, ); } private function dbWithNoRecipientFamilies(): BaseConnection { $query = new class { public function getResultArray(): array { return []; } }; $builder = $this->createMock(BaseBuilder::class); $builder->method('select')->willReturnSelf(); $builder->method('where')->willReturnSelf(); $builder->method('get')->willReturn($query); $db = $this->createMock(BaseConnection::class); $db->method('table')->willReturn($builder); $db->method('fieldExists')->with('user_type', 'users')->willReturn(false); return $db; } }