Files
Classeo/backend/tests/Unit/Administration/Infrastructure/Api/Provider/PeriodsProviderTest.php
Mathias STRASSER f19d0ae3ef 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.
2026-02-06 14:27:55 +01:00

224 lines
7.9 KiB
PHP

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