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
@@ -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);