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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user