412 lines
17 KiB
PHP
412 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\App\Services;
|
|
|
|
use App\Services\EmailService;
|
|
use App\Services\EnrollmentRegistrationEmailService;
|
|
use App\Services\EnrollmentTransitionService;
|
|
use App\Support\Enrollment\DeliberationDecision;
|
|
use App\Models\ConfigurationModel;
|
|
use CodeIgniter\Database\BaseBuilder;
|
|
use CodeIgniter\Database\BaseConnection;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class EnrollmentRegistrationEmailServiceTest extends TestCase
|
|
{
|
|
public function testPassedStudentMessageStaysConcise(): void
|
|
{
|
|
$service = $this->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.<br>', $html);
|
|
$this->assertStringContainsString('available.<br>', $html);
|
|
$this->assertStringContainsString('<strong>You must re-enroll Student Name before the make-up exam can be taken.</strong>', $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:</strong> $0.00', $html);
|
|
$this->assertStringContainsString('Total currently due:</strong> $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('</td><td', $html);
|
|
}
|
|
|
|
public function testStudentsTableWrapsStackedStudentBlocks(): void
|
|
{
|
|
$service = $this->service();
|
|
|
|
$html = $this->invoke($service, 'studentsTable', [['<table role="presentation"></table>']]);
|
|
|
|
$this->assertStringStartsWith('<div style="margin:12px 0 18px;">', $html);
|
|
$this->assertStringContainsString('<table role="presentation"></table>', $html);
|
|
$this->assertStringEndsWith('</div>', $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<mixed> $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;
|
|
}
|
|
}
|