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.
224 lines
7.9 KiB
PHP
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);
|
|
}
|
|
}
|