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