This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user