fix enrollment logic, add financial aid, fix class distribution
Tests / PHPUnit (push) Failing after 1m6s

This commit is contained in:
root
2026-08-15 15:07:16 -04:00
parent c12bb59372
commit 4603d9ced2
95 changed files with 6892 additions and 1295 deletions
@@ -75,6 +75,7 @@ final class EnrollmentTransitionServiceTest extends TestCase
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) {
@@ -91,6 +92,306 @@ final class EnrollmentTransitionServiceTest extends TestCase
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 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',
]]));
}
/**
* @param list<array<string, mixed>> $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, '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('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' => '[]',
];
}
});
$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 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, '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, '2025-2026', '2026-2027']);
$this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']);
$this->assertArrayNotHasKey('last_name_exception_carry_forward', $evaluation);
}
/**
* @param list<array<string, mixed>> $students
* @param list<array<string, mixed>> $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<mixed> $args
*/