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