feat: Gestion des périodes scolaires

L'administration d'un établissement nécessite de découper l'année
scolaire en trimestres ou semestres avant de pouvoir saisir les notes
et générer les bulletins.

Ce module permet de configurer les périodes par année scolaire
(current/previous/next résolus en UUID v5 déterministes), de modifier
les dates individuelles avec validation anti-chevauchement, et de
consulter la période en cours avec le décompte des jours restants.

Les dates par défaut de février s'adaptent aux années bissextiles.
Le repository utilise UPSERT transactionnel pour garantir l'intégrité
lors du changement de mode (trimestres ↔ semestres). Les domain events
de Subject sont étendus pour couvrir toutes les mutations (code,
couleur, description) en plus du renommage.
This commit is contained in:
2026-02-06 12:00:29 +01:00
parent 0d5a097c4c
commit f19d0ae3ef
69 changed files with 5201 additions and 121 deletions

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Put;
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsHandler;
use App\Administration\Infrastructure\Api\Processor\ConfigurePeriodsProcessor;
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecision;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class ConfigurePeriodsProcessorTest extends TestCase
{
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
private InMemoryPeriodConfigurationRepository $repository;
private TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryPeriodConfigurationRepository();
$this->tenantContext = new TenantContext();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2025-10-15 10:00:00');
}
};
}
#[Test]
public function itRejectsUnauthorizedAccess(): void
{
$processor = $this->createProcessor(granted: false);
$this->setTenant();
$data = new PeriodResource();
$data->periodType = 'trimester';
$this->expectException(AccessDeniedHttpException::class);
$processor->process($data, new Put(), ['academicYearId' => 'current']);
}
#[Test]
public function itRejectsRequestWithoutTenant(): void
{
$processor = $this->createProcessor(granted: true);
$data = new PeriodResource();
$data->periodType = 'trimester';
$this->expectException(UnauthorizedHttpException::class);
$processor->process($data, new Put(), ['academicYearId' => 'current']);
}
#[Test]
public function itRejectsInvalidAcademicYearId(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = new PeriodResource();
$data->periodType = 'trimester';
$this->expectException(NotFoundHttpException::class);
$processor->process($data, new Put(), ['academicYearId' => 'invalid']);
}
#[Test]
public function itConfiguresTrimesters(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = new PeriodResource();
$data->periodType = 'trimester';
$data->startYear = 2025;
$result = $processor->process($data, new Put(), ['academicYearId' => 'current']);
self::assertInstanceOf(PeriodResource::class, $result);
self::assertSame('trimester', $result->type);
self::assertCount(3, $result->periods);
self::assertContainsOnlyInstancesOf(PeriodItem::class, $result->periods);
}
#[Test]
public function itConfiguresSemesters(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = new PeriodResource();
$data->periodType = 'semester';
$data->startYear = 2025;
$result = $processor->process($data, new Put(), ['academicYearId' => 'current']);
self::assertSame('semester', $result->type);
self::assertCount(2, $result->periods);
}
#[Test]
public function itResolvesCurrentAndReturnsCorrectAcademicYearId(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
$expectedUuid = $resolver->resolve('current');
$data = new PeriodResource();
$data->periodType = 'trimester';
$data->startYear = 2025;
$result = $processor->process($data, new Put(), ['academicYearId' => 'current']);
self::assertSame($expectedUuid, $result->academicYearId);
}
#[Test]
public function itConfiguresNextYear(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = new PeriodResource();
$data->periodType = 'trimester';
$data->startYear = 2026;
$result = $processor->process($data, new Put(), ['academicYearId' => 'next']);
self::assertSame('trimester', $result->type);
self::assertCount(3, $result->periods);
}
private function setTenant(): void
{
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: TenantId::fromString(self::TENANT_UUID),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
}
private function createProcessor(bool $granted): ConfigurePeriodsProcessor
{
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
public function __construct(private readonly bool $granted)
{
}
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
{
return $this->granted;
}
};
$eventBus = new class implements MessageBusInterface {
public function dispatch(object $message, array $stamps = []): Envelope
{
return new Envelope($message);
}
};
$handler = new ConfigurePeriodsHandler($this->repository, $this->clock, $eventBus);
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
return new ConfigurePeriodsProcessor($handler, $this->tenantContext, $authChecker, $resolver, $this->clock);
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Patch;
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodHandler;
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Api\Processor\UpdatePeriodProcessor;
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecision;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class UpdatePeriodProcessorTest extends TestCase
{
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
private InMemoryPeriodConfigurationRepository $repository;
private TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryPeriodConfigurationRepository();
$this->tenantContext = new TenantContext();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2025-10-15 10:00:00');
}
};
}
#[Test]
public function itRejectsInvalidAcademicYearId(): void
{
$processor = $this->createProcessor();
$this->setTenant();
$data = new PeriodResource();
$data->startDate = '2025-09-02';
$data->endDate = '2025-11-30';
$this->expectException(NotFoundHttpException::class);
$processor->process($data, new Patch(), ['academicYearId' => 'invalid', 'sequence' => 1]);
}
#[Test]
public function itRejectsWhenNoPeriodsConfigured(): void
{
$processor = $this->createProcessor();
$this->setTenant();
$data = new PeriodResource();
$data->startDate = '2025-09-02';
$data->endDate = '2025-11-30';
$this->expectException(NotFoundHttpException::class);
$processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
}
#[Test]
public function itUpdatesPeriodDates(): void
{
$processor = $this->createProcessor();
$this->setTenant();
$this->seedPeriods();
$data = new PeriodResource();
$data->startDate = '2025-09-02';
$data->endDate = '2025-11-30';
$result = $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
self::assertInstanceOf(PeriodResource::class, $result);
self::assertSame('trimester', $result->type);
self::assertCount(3, $result->periods);
self::assertContainsOnlyInstancesOf(PeriodItem::class, $result->periods);
// First period has updated start date
self::assertSame('2025-09-02', $result->periods[0]->startDate);
self::assertSame('2025-11-30', $result->periods[0]->endDate);
}
#[Test]
public function itResolvesCurrentAcademicYearId(): void
{
$processor = $this->createProcessor();
$this->setTenant();
$this->seedPeriods();
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
$expectedUuid = $resolver->resolve('current');
$data = new PeriodResource();
$data->startDate = '2025-09-02';
$data->endDate = '2025-11-30';
$result = $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
self::assertSame($expectedUuid, $result->academicYearId);
}
#[Test]
public function itRejectsMissingStartDate(): void
{
$processor = $this->createProcessor();
$this->setTenant();
$this->seedPeriods();
$data = new PeriodResource();
$data->endDate = '2025-11-30';
$this->expectException(BadRequestHttpException::class);
$this->expectExceptionMessage('obligatoires');
$processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
}
#[Test]
public function itRejectsMissingEndDate(): void
{
$processor = $this->createProcessor();
$this->setTenant();
$this->seedPeriods();
$data = new PeriodResource();
$data->startDate = '2025-09-01';
$this->expectException(BadRequestHttpException::class);
$this->expectExceptionMessage('obligatoires');
$processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
}
#[Test]
public function itRejectsOverlappingDates(): void
{
$processor = $this->createProcessor();
$this->setTenant();
$this->seedPeriods();
$data = new PeriodResource();
// T1 end date overlaps with T2 start date (Dec 1)
$data->startDate = '2025-09-01';
$data->endDate = '2025-12-15';
$this->expectException(BadRequestHttpException::class);
$processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
}
private function setTenant(): void
{
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
}
private function seedPeriods(): void
{
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
$academicYearId = $resolver->resolve('current');
self::assertNotNull($academicYearId);
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
$this->repository->save(
TenantId::fromString(self::TENANT_UUID),
AcademicYearId::fromString($academicYearId),
$config,
);
}
private function createProcessor(): UpdatePeriodProcessor
{
$authChecker = new class implements AuthorizationCheckerInterface {
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
{
return true;
}
};
$eventBus = new class implements MessageBusInterface {
public function dispatch(object $message, array $stamps = []): Envelope
{
return new Envelope($message);
}
};
$handler = new UpdatePeriodHandler($this->repository, new NoOpGradeExistenceChecker(), $this->clock, $eventBus);
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
return new UpdatePeriodProcessor($handler, $this->tenantContext, $authChecker, $resolver);
}
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Get;
use App\Administration\Application\Query\GetPeriods\GetPeriodsHandler;
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Api\Provider\PeriodsProvider;
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AccessDecision;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class PeriodsProviderTest extends TestCase
{
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
private InMemoryPeriodConfigurationRepository $repository;
private TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryPeriodConfigurationRepository();
$this->tenantContext = new TenantContext();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2025-10-15 10:00:00');
}
};
}
#[Test]
public function itRejectsUnauthorizedAccess(): void
{
$provider = $this->createProvider(granted: false);
$this->setTenant();
$this->expectException(AccessDeniedHttpException::class);
$provider->provide(new Get(), ['academicYearId' => 'current']);
}
#[Test]
public function itRejectsRequestWithoutTenant(): void
{
$provider = $this->createProvider(granted: true);
$this->expectException(UnauthorizedHttpException::class);
$provider->provide(new Get(), ['academicYearId' => 'current']);
}
#[Test]
public function itRejectsInvalidAcademicYearId(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$this->expectException(NotFoundHttpException::class);
$provider->provide(new Get(), ['academicYearId' => 'invalid']);
}
#[Test]
public function itReturnsNullWhenNoPeriodsConfigured(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
self::assertNull($result);
}
#[Test]
public function itResolvesCurrentToValidUuidAndReturnsPeriods(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
// Seed periods using the same resolved UUID
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
$academicYearId = $resolver->resolve('current');
self::assertNotNull($academicYearId);
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
$this->repository->save(
TenantId::fromString(self::TENANT_UUID),
AcademicYearId::fromString($academicYearId),
$config,
);
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
self::assertInstanceOf(PeriodResource::class, $result);
self::assertSame($academicYearId, $result->academicYearId);
self::assertSame('trimester', $result->type);
self::assertCount(3, $result->periods);
self::assertContainsOnlyInstancesOf(PeriodItem::class, $result->periods);
}
#[Test]
public function itResolvesNextAcademicYear(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$result = $provider->provide(new Get(), ['academicYearId' => 'next']);
// No periods for next year → null
self::assertNull($result);
}
#[Test]
public function itResolvesPreviousAcademicYear(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$result = $provider->provide(new Get(), ['academicYearId' => 'previous']);
// No periods for previous year → null
self::assertNull($result);
}
#[Test]
public function itReturnsPeriodItemsWithCorrectFields(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
$academicYearId = $resolver->resolve('current');
self::assertNotNull($academicYearId);
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
$this->repository->save(
TenantId::fromString(self::TENANT_UUID),
AcademicYearId::fromString($academicYearId),
$config,
);
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
self::assertNotNull($result);
$firstPeriod = $result->periods[0];
self::assertInstanceOf(PeriodItem::class, $firstPeriod);
self::assertSame(1, $firstPeriod->sequence);
self::assertSame('T1', $firstPeriod->label);
self::assertSame('2025-09-01', $firstPeriod->startDate);
self::assertSame('2025-11-30', $firstPeriod->endDate);
self::assertTrue($firstPeriod->isCurrent);
}
#[Test]
public function itReturnsCurrentPeriodBanner(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
$academicYearId = $resolver->resolve('current');
self::assertNotNull($academicYearId);
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
$this->repository->save(
TenantId::fromString(self::TENANT_UUID),
AcademicYearId::fromString($academicYearId),
$config,
);
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
self::assertNotNull($result);
self::assertInstanceOf(PeriodItem::class, $result->currentPeriod);
self::assertSame('T1', $result->currentPeriod->label);
self::assertTrue($result->currentPeriod->isCurrent);
}
private function setTenant(): void
{
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
}
private function createProvider(bool $granted): PeriodsProvider
{
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
public function __construct(private readonly bool $granted)
{
}
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
{
return $this->granted;
}
};
$handler = new GetPeriodsHandler($this->repository, $this->clock);
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
return new PeriodsProvider($handler, $this->tenantContext, $authChecker, $resolver);
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Service;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CurrentAcademicYearResolverTest extends TestCase
{
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
#[Test]
public function itPassesThroughValidUuid(): void
{
$resolver = $this->createResolver('2026-02-05 10:00:00');
$uuid = '550e8400-e29b-41d4-a716-446655440099';
self::assertSame($uuid, $resolver->resolve($uuid));
}
#[Test]
public function itReturnsNullForInvalidIdentifier(): void
{
$resolver = $this->createResolver('2026-02-05 10:00:00');
self::assertNull($resolver->resolve('invalid'));
self::assertNull($resolver->resolve(''));
self::assertNull($resolver->resolve('past'));
}
#[Test]
public function itResolvesCurrentBeforeSeptember(): void
{
// February 2026 → school year 2025-2026
$resolver = $this->createResolver('2026-02-05 10:00:00');
$result = $resolver->resolve('current');
self::assertNotNull($result);
self::assertTrue(\Ramsey\Uuid\Uuid::isValid($result));
}
#[Test]
public function itResolvesCurrentAfterSeptember(): void
{
// October 2025 → school year 2025-2026
$resolver = $this->createResolver('2025-10-15 10:00:00');
$result = $resolver->resolve('current');
self::assertNotNull($result);
self::assertTrue(\Ramsey\Uuid\Uuid::isValid($result));
}
#[Test]
public function itResolvesSameUuidForSameSchoolYear(): void
{
// Both dates are in school year 2025-2026
$resolverOct = $this->createResolver('2025-10-15 10:00:00');
$resolverFeb = $this->createResolver('2026-02-05 10:00:00');
self::assertSame(
$resolverOct->resolve('current'),
$resolverFeb->resolve('current'),
);
}
#[Test]
public function itResolvesDifferentUuidForDifferentSchoolYears(): void
{
// October 2025 → 2025-2026, October 2026 → 2026-2027
$resolver2025 = $this->createResolver('2025-10-15 10:00:00');
$resolver2026 = $this->createResolver('2026-10-15 10:00:00');
self::assertNotSame(
$resolver2025->resolve('current'),
$resolver2026->resolve('current'),
);
}
#[Test]
public function itResolvesNextYear(): void
{
// February 2026, current = 2025-2026, next = 2026-2027
$resolver = $this->createResolver('2026-02-05 10:00:00');
$current = $resolver->resolve('current');
$next = $resolver->resolve('next');
self::assertNotNull($next);
self::assertTrue(\Ramsey\Uuid\Uuid::isValid($next));
self::assertNotSame($current, $next);
}
#[Test]
public function itResolvesPreviousYear(): void
{
// February 2026, current = 2025-2026, previous = 2024-2025
$resolver = $this->createResolver('2026-02-05 10:00:00');
$current = $resolver->resolve('current');
$previous = $resolver->resolve('previous');
self::assertNotNull($previous);
self::assertTrue(\Ramsey\Uuid\Uuid::isValid($previous));
self::assertNotSame($current, $previous);
}
#[Test]
public function nextOfCurrentYearMatchesCurrentOfNextYear(): void
{
// "next" from Feb 2026 should equal "current" from Oct 2026
$resolverFeb2026 = $this->createResolver('2026-02-05 10:00:00');
$resolverOct2026 = $this->createResolver('2026-10-15 10:00:00');
self::assertSame(
$resolverFeb2026->resolve('next'),
$resolverOct2026->resolve('current'),
);
}
#[Test]
public function previousOfCurrentYearMatchesCurrentOfPreviousYear(): void
{
// "previous" from Feb 2026 (2024-2025) should equal "current" from Oct 2024
$resolverFeb2026 = $this->createResolver('2026-02-05 10:00:00');
$resolverOct2024 = $this->createResolver('2024-10-15 10:00:00');
self::assertSame(
$resolverFeb2026->resolve('previous'),
$resolverOct2024->resolve('current'),
);
}
#[Test]
public function itResolvesDifferentUuidsForDifferentTenants(): void
{
$otherTenantUuid = '550e8400-e29b-41d4-a716-446655440099';
$resolver1 = $this->createResolver('2026-02-05 10:00:00', self::TENANT_UUID);
$resolver2 = $this->createResolver('2026-02-05 10:00:00', $otherTenantUuid);
self::assertNotSame(
$resolver1->resolve('current'),
$resolver2->resolve('current'),
);
}
#[Test]
public function itIsDeterministic(): void
{
$resolver = $this->createResolver('2026-02-05 10:00:00');
self::assertSame(
$resolver->resolve('current'),
$resolver->resolve('current'),
);
}
#[Test]
public function septemberBelongsToNewSchoolYear(): void
{
// September 1st 2026 should be in school year 2026-2027
$resolverSept = $this->createResolver('2026-09-01 08:00:00');
$resolverOct = $this->createResolver('2026-10-15 10:00:00');
self::assertSame(
$resolverSept->resolve('current'),
$resolverOct->resolve('current'),
);
}
#[Test]
public function augustBelongsToPreviousSchoolYear(): void
{
// August 31st 2026 should still be in school year 2025-2026
$resolverAug = $this->createResolver('2026-08-31 23:59:59');
$resolverFeb = $this->createResolver('2026-02-05 10:00:00');
self::assertSame(
$resolverAug->resolve('current'),
$resolverFeb->resolve('current'),
);
}
private function createResolver(string $dateTime, string $tenantUuid = self::TENANT_UUID): CurrentAcademicYearResolver
{
$tenantContext = new TenantContext();
$tenantContext->setCurrentTenant(new TenantConfig(
tenantId: TenantId::fromString($tenantUuid),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
$clock = new class($dateTime) implements Clock {
public function __construct(private readonly string $dateTime)
{
}
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->dateTime);
}
};
return new CurrentAcademicYearResolver($tenantContext, $clock);
}
}