fix enrollment for the new school-year
Tests / PHPUnit (push) Successful in 1m26s

This commit is contained in:
root
2026-08-07 23:43:31 -04:00
parent b6f3b14e7b
commit 1ef2800f12
66 changed files with 8997 additions and 498 deletions
@@ -3,12 +3,13 @@
namespace Tests\App\Controllers\View;
use App\Controllers\View\ParentController;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Test\CIUnitTestCase;
use ReflectionMethod;
final class ParentControllerAgeTest extends CIUnitTestCase
{
public function testEnrollmentAgeUsesSchoolYearStartYearCutoff(): void
public function testEnrollmentAgeUsesSeptemberFirstSchoolYearCutoff(): void
{
$controller = new class extends ParentController {
public function __construct()
@@ -19,8 +20,9 @@ final class ParentControllerAgeTest extends CIUnitTestCase
$method = new ReflectionMethod(ParentController::class, 'calculateAgeAsOfSchoolYearStartYear');
$method->setAccessible(true);
$this->assertSame(6, $method->invoke($controller, '2019-09-15', '2025-2026'));
$this->assertSame(5, $method->invoke($controller, '2019-09-15', '2025-2026'));
$this->assertSame(5, $method->invoke($controller, '2020-01-01', '2025-2026'));
$this->assertSame(4, $method->invoke($controller, '2020-09-02', '2025-2026'));
}
public function testEnrollmentAgeRejectsInvalidInputs(): void
@@ -39,4 +41,127 @@ final class ParentControllerAgeTest extends CIUnitTestCase
$this->assertNull($method->invoke($controller, '2026-01-01', '2025-2026'));
$this->assertNull($method->invoke($controller, '2019-09-15', ''));
}
public function testRegistrationValidationUsesDecemberThirtyFirstOnlyForMinimumAgeGrace(): void
{
$controller = new class extends ParentController {
public function __construct()
{
}
};
$this->assertTrue($controller->validateDobAge('2020-12-31', '2025-12-31', 5, 18, '2025-09-01')['isValid']);
$this->assertFalse($controller->validateDobAge('2021-01-01', '2025-12-31', 5, 18, '2025-09-01')['isValid']);
$this->assertTrue($controller->validateDobAge('2006-09-02', '2025-12-31', 5, 18, '2025-09-01')['isValid']);
$this->assertFalse($controller->validateDobAge('2006-08-31', '2025-12-31', 5, 18, '2025-09-01')['isValid']);
}
public function testEnrollmentEligibilityBlocksFinalDecisionStatuses(): void
{
$controller = new class extends ParentController {
public function __construct()
{
}
};
$method = new ReflectionMethod(ParentController::class, 'enrollmentEligibilityMessageForStudent');
$method->setAccessible(true);
foreach (['expel', 'withdrawn', 'deferred decision'] as $decision) {
$message = $method->invoke(
$controller,
['firstname' => 'Ali', 'lastname' => 'Ahmed', 'dob' => '2010-01-01'],
['decision' => $decision, 'source' => 'manual', 'class_section_name' => 'Level 5'],
'2025-2026',
null
);
$this->assertTrue($message['blocking'], $decision . ' should block enrollment.');
$this->assertNotSame('', $message['message']);
}
}
public function testEnrollmentEligibilityBlocksAdultStudentsOnSeptemberFirst(): void
{
$controller = new class extends ParentController {
public function __construct()
{
}
};
$method = new ReflectionMethod(ParentController::class, 'enrollmentEligibilityMessageForStudent');
$method->setAccessible(true);
$message = $method->invoke(
$controller,
['firstname' => 'Ali', 'lastname' => 'Ahmed', 'dob' => '2006-08-31'],
['decision' => 'pass', 'source' => 'manual', 'class_section_name' => 'Level 9'],
'2025-2026',
null
);
$this->assertTrue($message['blocking']);
$this->assertStringContainsString('18 years old or older on September 1', $message['message']);
$this->assertStringContainsString('A parent or guardian cannot complete registration', $message['message']);
}
public function testEnrollmentEligibilityAllowsFallMakeupExamWithWarning(): void
{
$controller = new class extends ParentController {
public function __construct()
{
}
};
$method = new ReflectionMethod(ParentController::class, 'enrollmentEligibilityMessageForStudent');
$method->setAccessible(true);
$message = $method->invoke(
$controller,
['firstname' => 'Ali', 'lastname' => 'Ahmed', 'dob' => '2010-01-01'],
['decision' => 'Make Up Exam in Fall', 'source' => 'manual', 'class_section_name' => 'Level 5'],
'2025-2026',
'2025-09-10'
);
$this->assertFalse($message['blocking']);
$this->assertSame('warning', $message['level']);
$this->assertStringContainsString('will initially remain in the same grade', $message['message']);
}
public function testRequiredActionDoesNotDefaultToContactAdministration(): void
{
$controller = new class extends ParentController {
public function __construct()
{
}
};
$method = new ReflectionMethod(ParentController::class, 'requiredActionLabel');
$method->setAccessible(true);
$this->assertSame(
'Complete re-enrollment before the registration deadline.',
$method->invoke($controller, null)
);
$this->assertSame(
'Complete re-enrollment before the registration deadline.',
$method->invoke($controller, [
'blockers' => [],
'deliberation_decision' => DeliberationDecision::PASSED,
'parent_enrollment_allowed' => true,
])
);
$this->assertSame(
'Contact the school administration.',
$method->invoke($controller, [
'blockers' => ['Final decision is pending.'],
'deliberation_decision' => DeliberationDecision::DEFERRED_DECISION,
'parent_enrollment_allowed' => false,
])
);
}
}
@@ -15,14 +15,43 @@ use Config\Services;
final class SchoolYearWritableFilterFakeModel extends SchoolYearModel
{
private array $filters = [];
public function __construct(private readonly array $row)
{
}
public function find($id = null)
{
return ((int) $id === (int) ($this->row['id'] ?? 0)) ? $this->row : null;
}
public function active(): ?array
{
return $this->row;
}
public function where($key, $value = null, ?bool $escape = null)
{
$this->filters[] = [$key, $value];
return $this;
}
public function first()
{
foreach ($this->filters as [$key, $value]) {
if (($this->row[$key] ?? null) !== $value) {
$this->filters = [];
return null;
}
}
$this->filters = [];
return $this->row;
}
}
final class SchoolYearWritableFilterTest extends CIUnitTestCase
@@ -59,6 +88,21 @@ final class SchoolYearWritableFilterTest extends CIUnitTestCase
$this->assertNull((new SchoolYearWritableFilter())->before($request));
}
public function testPostBodySchoolYearAgainstClosedYearIsBlocked(): void
{
$this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']);
$request = $this->request('POST', 'https://example.test/grading/below-60/decisions/save');
$request->setHeader('Accept', 'application/json');
$request->setGlobal('post', ['school_year' => '2025-2026']);
$result = (new SchoolYearWritableFilter())->before($request);
$this->assertInstanceOf(Response::class, $result);
$this->assertSame(409, $result->getStatusCode());
$this->assertStringContainsString('Read-only school year', $result->getBody());
}
public function testSchoolYearSelectionPostIsExempt(): void
{
$this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']);
@@ -0,0 +1,142 @@
<?php
namespace Tests\App\Services;
use App\Services\EmailService;
use App\Services\EnrollmentRegistrationEmailService;
use App\Services\EnrollmentTransitionService;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\BaseConnection;
use PHPUnit\Framework\TestCase;
final class EnrollmentRegistrationEmailServiceTest extends TestCase
{
public function testPassedStudentUsesReEnrollmentOpeningLanguage(): 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('We are pleased to inform you that Student Name has successfully passed Grade 8.', $message);
$this->assertStringContainsString('Re-enrollment for the new school year will open on August 1, 2026.', $message);
$this->assertStringContainsString('Please make sure to re-enroll your child before August 31, 2026', $message);
$this->assertStringContainsString('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 testFinancialSectionShowsWhenAnyAmountIsDue(): void
{
$service = $this->service();
$html = $this->invoke($service, 'financialSection', [10, [
'registration_fee' => 25,
'tuition_due_at_registration' => 0,
'mandatory_fees' => 0,
], null]);
$this->assertStringContainsString('Family Account Information', $html);
$this->assertStringContainsString('Total currently due:</strong> $25.00', $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 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']));
}
/**
* @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): EnrollmentRegistrationEmailService
{
$db ??= $this->createMock(BaseConnection::class);
return new EnrollmentRegistrationEmailService(
$db,
new EnrollmentTransitionService($db),
$emailService ?? $this->createMock(EmailService::class),
);
}
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;
}
}
@@ -0,0 +1,104 @@
<?php
namespace Tests\App\Services;
use App\Services\EnrollmentTransitionService;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\BaseConnection;
use PHPUnit\Framework\TestCase;
final class EnrollmentTransitionServiceTest extends TestCase
{
public function testPassedClassNameWithPrefixFindsNextNumericGrade(): void
{
$service = $this->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 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('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;
}
});
return $builder;
}
/**
* @param list<mixed> $args
*/
private function invoke(EnrollmentTransitionService $service, string $method, array $args): mixed
{
$reflection = new \ReflectionMethod($service, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($service, $args);
}
}
@@ -12,9 +12,14 @@ use Config\App;
final class SchoolYearContextRequest extends MockIncomingRequest
{
public function __construct(private readonly array $gets = [])
public function __construct(
private readonly array $gets = [],
private readonly array $posts = [],
string $method = 'GET'
)
{
parent::__construct(config(App::class), new URI('https://test.alrahmaisgl.org'), 'php://input', new UserAgent());
$this->setMethod($method);
}
public function getGet($key = null, $filter = null, $default = null)
@@ -25,6 +30,15 @@ final class SchoolYearContextRequest extends MockIncomingRequest
return $this->gets[$key] ?? $default;
}
public function getPost($index = null, $filter = null, $flags = null)
{
if ($index === null) {
return $this->posts;
}
return $this->posts[$index] ?? null;
}
}
final class SchoolYearContextFakeModel extends SchoolYearModel
@@ -79,6 +93,11 @@ final class SchoolYearContextFakeModel extends SchoolYearModel
return $limit !== null ? array_slice($rows, $offset, $limit) : array_slice($rows, $offset);
}
public function first()
{
return $this->findAll(1)[0] ?? null;
}
}
final class SchoolYearContextServiceTest extends CIUnitTestCase
@@ -110,6 +129,56 @@ final class SchoolYearContextServiceTest extends CIUnitTestCase
$this->assertSame(1, session()->get('selected_school_year_id'));
}
public function testPostSchoolYearNameTakesPrecedenceOverActiveYearForWrites(): void
{
$service = new SchoolYearContextService(new SchoolYearContextFakeModel([
1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closed'],
2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'active'],
]));
$context = $service->resolve(new SchoolYearContextRequest(
posts: ['school_year' => '2025-2026'],
method: 'POST'
));
$this->assertSame(1, $context->id());
$this->assertSame('2025-2026', $context->yearName());
$this->assertTrue($context->isReadonly());
$this->assertTrue($context->isExplicitSelection());
}
public function testPostSchoolYearIdTakesPrecedenceOverActiveYearForWrites(): void
{
$service = new SchoolYearContextService(new SchoolYearContextFakeModel([
1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closed'],
2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'active'],
]));
$context = $service->resolve(new SchoolYearContextRequest(
posts: ['school_year_id' => '1'],
method: 'POST'
));
$this->assertSame(1, $context->id());
$this->assertSame('2025-2026', $context->yearName());
$this->assertTrue($context->isReadonly());
}
public function testCanResolveContextFromStoredSchoolYearName(): void
{
$service = new SchoolYearContextService(new SchoolYearContextFakeModel([
1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closed'],
2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'active'],
]));
$context = $service->forYearName('2025-2026');
$this->assertSame(1, $context->id());
$this->assertSame('2025-2026', $context->yearName());
$this->assertTrue($context->isReadonly());
$this->assertTrue($context->isExplicitSelection());
}
public function testInvalidSessionSelectionFallsBackToActiveYearAndClearsSession(): void
{
session()->set('selected_school_year_id', 99);
@@ -0,0 +1,36 @@
<?php
namespace Tests\App\Support\Enrollment;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Test\CIUnitTestCase;
final class DeliberationDecisionTest extends CIUnitTestCase
{
public function testHistoricalDecisionValuesMapToCanonicalValues(): void
{
$cases = [
'Pass' => DeliberationDecision::PASSED,
'Passed' => DeliberationDecision::PASSED,
'Repeat Class' => DeliberationDecision::REPEAT_CLASS,
'Make-up exam in fall' => DeliberationDecision::MAKE_UP_EXAM,
'Expel' => DeliberationDecision::EXPELLED,
'Expelled' => DeliberationDecision::EXPELLED,
'Withdrawn' => DeliberationDecision::WITHDRAWN,
'Withdraw' => DeliberationDecision::WITHDRAWN,
'Widthrwan' => DeliberationDecision::WITHDRAWN,
'Deferred' => DeliberationDecision::DEFERRED_DECISION,
'Deferred decision' => DeliberationDecision::DEFERRED_DECISION,
];
foreach ($cases as $input => $expected) {
$this->assertSame($expected, DeliberationDecision::normalize($input), $input);
}
}
public function testBlankAndUnknownDecisionsRemainUnmapped(): void
{
$this->assertNull(DeliberationDecision::normalize(''));
$this->assertNull(DeliberationDecision::normalize('Teacher review needed'));
}
}
@@ -0,0 +1,78 @@
<?php
namespace Tests\App\Support\Enrollment;
use App\Support\Enrollment\EnrollmentEligibility;
use CodeIgniter\Test\CIUnitTestCase;
final class EnrollmentEligibilityTest extends CIUnitTestCase
{
public function testParentEnrollmentIsBlockedWhenStudentIsEighteenOnSeptemberFirst(): void
{
$message = EnrollmentEligibility::parentDecisionMessage(
['firstname' => 'Adult', 'lastname' => 'Student', 'dob' => '2008-09-01'],
['decision' => 'Pass', 'source' => 'manual'],
'2026-2027'
);
$this->assertTrue($message['blocking']);
$this->assertStringContainsString('18 years old or older on September 1', $message['message']);
}
public function testParentEnrollmentIsAllowedWhenStudentTurnsEighteenAfterSeptemberFirst(): void
{
$message = EnrollmentEligibility::parentDecisionMessage(
['firstname' => 'Minor', 'lastname' => 'Student', 'dob' => '2008-09-02'],
['decision' => 'Pass', 'source' => 'manual'],
'2026-2027'
);
$this->assertFalse($message['blocking']);
}
public function testBlockedDecisionMessagesUseRequiredAdministrativeText(): void
{
$expelled = EnrollmentEligibility::parentDecisionMessage(
['firstname' => 'A', 'lastname' => 'B', 'dob' => '2010-01-01'],
['decision' => 'Expel', 'source' => 'manual'],
'2026-2027'
);
$withdrawn = EnrollmentEligibility::parentDecisionMessage(
['firstname' => 'A', 'lastname' => 'B', 'dob' => '2010-01-01'],
['decision' => 'Widthrwan', 'source' => 'manual'],
'2026-2027'
);
$deferred = EnrollmentEligibility::parentDecisionMessage(
['firstname' => 'A', 'lastname' => 'B', 'dob' => '2010-01-01'],
['decision' => 'Deferred', 'source' => 'manual'],
'2026-2027'
);
$this->assertTrue($expelled['blocking']);
$this->assertTrue($withdrawn['blocking']);
$this->assertTrue($deferred['blocking']);
$this->assertStringContainsString('A B', $expelled['message']);
$this->assertStringContainsString('A B', $withdrawn['message']);
$this->assertStringContainsString('A B', $deferred['message']);
$this->assertStringContainsString(EnrollmentEligibility::EXPELLED_MESSAGE, $expelled['message']);
$this->assertStringContainsString(EnrollmentEligibility::WITHDRAWN_MESSAGE, $withdrawn['message']);
$this->assertStringContainsString(EnrollmentEligibility::DEFERRED_MESSAGE, $deferred['message']);
$this->assertStringContainsString('decision is expelled', $expelled['message']);
$this->assertStringContainsString('decision is withdrawn', $withdrawn['message']);
$this->assertStringContainsString('decision is deferred', $deferred['message']);
}
public function testPendingSourceUsesMissingDecisionMessageNotDeferredDecisionMessage(): void
{
$message = EnrollmentEligibility::parentDecisionMessage(
['firstname' => 'A', 'lastname' => 'B', 'dob' => '2010-01-01'],
['decision' => '', 'source' => 'pending'],
'2026-2027'
);
$this->assertTrue($message['blocking']);
$this->assertStringContainsString('A B', $message['message']);
$this->assertStringContainsString(EnrollmentEligibility::MISSING_DECISION_MESSAGE, $message['message']);
$this->assertStringNotContainsString(EnrollmentEligibility::DEFERRED_MESSAGE, $message['message']);
}
}