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

@@ -134,6 +134,14 @@ services:
App\Administration\Domain\Repository\SubjectRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSubjectRepository
# Period Configuration Repository (Story 2.3 - Gestion des périodes)
App\Administration\Domain\Repository\PeriodConfigurationRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrinePeriodConfigurationRepository
# GradeExistenceChecker (stub until Notes module exists)
App\Administration\Application\Port\GradeExistenceChecker:
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
# GeoLocation Service (null implementation - no geolocation)
App\Administration\Application\Port\GeoLocationService:
alias: App\Administration\Infrastructure\Service\NullGeoLocationService

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Migration pour créer la table academic_periods (périodes scolaires).
*
* @see FR75 - Structurer l'année pour bulletins et moyennes
*/
final class Version20260205100002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create academic_periods table for school period management';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE academic_periods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
academic_year_id UUID NOT NULL,
period_type VARCHAR(20) NOT NULL,
sequence INT NOT NULL,
label VARCHAR(20) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
$this->addSql('CREATE INDEX idx_academic_periods_tenant_id ON academic_periods(tenant_id)');
$this->addSql('CREATE INDEX idx_academic_periods_year ON academic_periods(academic_year_id)');
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX idx_academic_periods_unique_sequence
ON academic_periods (tenant_id, academic_year_id, sequence)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS academic_periods');
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ConfigurePeriods;
/**
* Command pour configurer les périodes d'une année scolaire (trimestres ou semestres).
*/
final readonly class ConfigurePeriodsCommand
{
/**
* @param string $periodType 'trimester' ou 'semester'
* @param int $startYear Année de début (ex: 2025 pour 2025-2026)
*/
public function __construct(
public string $tenantId,
public string $academicYearId,
public string $periodType,
public int $startYear,
) {
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ConfigurePeriods;
use App\Administration\Domain\Event\PeriodesConfigurees;
use App\Administration\Domain\Exception\PeriodesDejaConfigureesException;
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Handler pour configurer les périodes d'une année scolaire.
*
* Crée les périodes par défaut (trimestres ou semestres) pour l'année donnée.
* Refuse si des périodes existent déjà.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ConfigurePeriodsHandler
{
public function __construct(
private PeriodConfigurationRepository $repository,
private Clock $clock,
private MessageBusInterface $eventBus,
) {
}
public function __invoke(ConfigurePeriodsCommand $command): PeriodConfiguration
{
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$periodType = PeriodType::from($command->periodType);
$existing = $this->repository->findByAcademicYear($tenantId, $academicYearId);
if ($existing !== null) {
throw PeriodesDejaConfigureesException::pourAnnee($command->academicYearId);
}
$configuration = DefaultPeriods::forType($periodType, $command->startYear);
$this->repository->save($tenantId, $academicYearId, $configuration);
$this->eventBus->dispatch(new PeriodesConfigurees(
academicYearId: $academicYearId,
tenantId: $tenantId,
periodType: $periodType,
periodCount: count($configuration->periods),
occurredOn: $this->clock->now(),
));
return $configuration;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdatePeriod;
/**
* Command pour modifier les dates d'une période existante.
*/
final readonly class UpdatePeriodCommand
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public int $sequence,
public string $startDate,
public string $endDate,
public bool $confirmImpact = false,
) {
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdatePeriod;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Event\PeriodeModifiee;
use App\Administration\Domain\Exception\PeriodeAvecNotesException;
use App\Administration\Domain\Exception\PeriodeNonTrouveeException;
use App\Administration\Domain\Exception\PeriodesNonConfigureesException;
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Handler pour modifier les dates d'une période scolaire.
*
* Reconstruit la PeriodConfiguration avec la période modifiée,
* ce qui déclenche la revalidation complète (chevauchement, contiguïté).
* Si la période contient des notes, une confirmation explicite est requise.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdatePeriodHandler
{
public function __construct(
private PeriodConfigurationRepository $repository,
private GradeExistenceChecker $gradeExistenceChecker,
private Clock $clock,
private MessageBusInterface $eventBus,
) {
}
public function __invoke(UpdatePeriodCommand $command): PeriodConfiguration
{
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$existing = $this->repository->findByAcademicYear($tenantId, $academicYearId);
if ($existing === null) {
throw PeriodesNonConfigureesException::pourAnnee($command->academicYearId);
}
$found = false;
$targetLabel = '';
$updatedPeriods = [];
foreach ($existing->periods as $period) {
if ($period->sequence === $command->sequence) {
$found = true;
$targetLabel = $period->label;
$updatedPeriods[] = new AcademicPeriod(
sequence: $period->sequence,
label: $period->label,
startDate: new DateTimeImmutable($command->startDate),
endDate: new DateTimeImmutable($command->endDate),
);
} else {
$updatedPeriods[] = $period;
}
}
if (!$found) {
throw PeriodeNonTrouveeException::pourSequence($command->sequence, $command->academicYearId);
}
if (!$command->confirmImpact) {
$hasGrades = $this->gradeExistenceChecker->hasGradesInPeriod(
$tenantId,
$academicYearId,
$command->sequence,
);
if ($hasGrades) {
throw PeriodeAvecNotesException::confirmationRequise($targetLabel);
}
}
$newConfiguration = new PeriodConfiguration($existing->type, $updatedPeriods);
$this->repository->save($tenantId, $academicYearId, $newConfiguration);
$this->eventBus->dispatch(new PeriodeModifiee(
academicYearId: $academicYearId,
tenantId: $tenantId,
periodSequence: $command->sequence,
periodLabel: $targetLabel,
occurredOn: $this->clock->now(),
));
return $newConfiguration;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
/**
* Port pour vérifier l'existence de notes dans une période.
*
* Implémenté par le module Notes/Évaluations (Epic 6).
* L'implémentation par défaut retourne false tant que le module n'existe pas.
*/
interface GradeExistenceChecker
{
public function hasGradesInPeriod(
TenantId $tenantId,
AcademicYearId $academicYearId,
int $periodSequence,
): bool;
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetPeriods;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour récupérer la configuration des périodes d'une année scolaire.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetPeriodsHandler
{
public function __construct(
private PeriodConfigurationRepository $repository,
private Clock $clock,
) {
}
public function __invoke(GetPeriodsQuery $query): ?PeriodsResultDto
{
$config = $this->repository->findByAcademicYear(
TenantId::fromString($query->tenantId),
AcademicYearId::fromString($query->academicYearId),
);
if ($config === null) {
return null;
}
$now = $this->clock->now();
$currentPeriod = $config->currentPeriod($now);
$periodDtos = array_map(
static fn ($period) => PeriodDto::fromDomain($period, $now),
$config->periods,
);
$currentPeriodDto = $currentPeriod !== null
? PeriodDto::fromDomain($currentPeriod, $now)
: null;
return new PeriodsResultDto(
type: $config->type->value,
periods: $periodDtos,
currentPeriod: $currentPeriodDto,
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetPeriods;
/**
* Query pour récupérer la configuration des périodes d'une année scolaire.
*/
final readonly class GetPeriodsQuery
{
public function __construct(
public string $tenantId,
public string $academicYearId,
) {
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetPeriods;
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
use DateTimeImmutable;
/**
* DTO pour représenter une période scolaire dans les réponses de query.
*/
final readonly class PeriodDto
{
public function __construct(
public int $sequence,
public string $label,
public string $startDate,
public string $endDate,
public bool $isCurrent,
public int $daysRemaining,
public bool $isPast,
) {
}
public static function fromDomain(AcademicPeriod $period, DateTimeImmutable $now): self
{
return new self(
sequence: $period->sequence,
label: $period->label,
startDate: $period->startDate->format('Y-m-d'),
endDate: $period->endDate->format('Y-m-d'),
isCurrent: $period->containsDate($now),
daysRemaining: $period->daysRemaining($now),
isPast: $period->isPast($now),
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetPeriods;
/**
* DTO encapsulant la configuration complète des périodes.
*/
final readonly class PeriodsResultDto
{
/**
* @param PeriodDto[] $periods
*/
public function __construct(
public string $type,
public array $periods,
public ?PeriodDto $currentPeriod,
) {
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\HasGradesInPeriod;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour vérifier la présence de notes dans une période.
*
* Délègue au port GradeExistenceChecker qui sera implémenté par le module Notes
* quand il existera. Pour l'instant, retourne toujours false (pas de notes).
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class HasGradesInPeriodHandler
{
public function __construct(
private GradeExistenceChecker $gradeExistenceChecker,
) {
}
public function __invoke(HasGradesInPeriodQuery $query): bool
{
return $this->gradeExistenceChecker->hasGradesInPeriod(
TenantId::fromString($query->tenantId),
AcademicYearId::fromString($query->academicYearId),
$query->periodSequence,
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\HasGradesInPeriod;
/**
* Query pour vérifier si des notes existent dans une période donnée.
*
* Utilisée pour avertir l'administrateur lors de la modification
* des dates d'une période passée contenant des évaluations.
*/
final readonly class HasGradesInPeriodQuery
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public int $periodSequence,
) {
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
@@ -20,8 +19,9 @@ final readonly class MatiereModifiee implements DomainEvent
public function __construct(
public SubjectId $subjectId,
public TenantId $tenantId,
public SubjectName $ancienNom,
public SubjectName $nouveauNom,
public string $champ,
public ?string $ancienneValeur,
public ?string $nouvelleValeur,
private DateTimeImmutable $occurredOn,
) {
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lors de la modification des dates d'une période scolaire.
*/
final readonly class PeriodeModifiee implements DomainEvent
{
public function __construct(
public AcademicYearId $academicYearId,
public TenantId $tenantId,
public int $periodSequence,
public string $periodLabel,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->academicYearId->value;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lors de la configuration initiale des périodes d'une année scolaire.
*/
final readonly class PeriodesConfigurees implements DomainEvent
{
public function __construct(
public AcademicYearId $academicYearId,
public TenantId $tenantId,
public PeriodType $periodType,
public int $periodCount,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->academicYearId->value;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class InvalidPeriodCountException extends RuntimeException
{
public static function forType(string $type, int $expected, int $actual): self
{
return new self(sprintf(
'Le mode "%s" attend %d période(s), mais %d fournie(s).',
$type,
$expected,
$actual,
));
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class InvalidPeriodDatesException extends RuntimeException
{
public static function endBeforeStart(string $label, string $start, string $end): self
{
return new self(sprintf(
'La date de fin (%s) de la période "%s" doit être postérieure à la date de début (%s).',
$end,
$label,
$start,
));
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class PeriodeAvecNotesException extends RuntimeException
{
public static function confirmationRequise(string $label): self
{
return new self(sprintf(
'La période "%s" contient des notes. La modification peut impacter les bulletins existants. Confirmez avec confirmImpact=true.',
$label,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class PeriodeNonTrouveeException extends RuntimeException
{
public static function pourSequence(int $sequence, string $academicYearId): self
{
return new self(sprintf(
'Aucune période avec la séquence %d trouvée pour l\'année scolaire %s.',
$sequence,
$academicYearId,
));
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
final class PeriodesDejaConfigureesException extends RuntimeException
{
public static function pourAnnee(string $academicYearId): self
{
return new self("Les périodes sont déjà configurées pour l'année scolaire {$academicYearId}.");
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
final class PeriodesNonConfigureesException extends RuntimeException
{
public static function pourAnnee(string $academicYearId): self
{
return new self("Aucune période configurée pour l'année scolaire {$academicYearId}.");
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class PeriodsCoverageGapException extends RuntimeException
{
public static function gapBetween(string $periodA, string $periodB): self
{
return new self(sprintf(
'Il y a un trou de couverture entre les périodes "%s" et "%s". Les périodes doivent être contiguës.',
$periodA,
$periodB,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use function sprintf;
final class PeriodsOverlapException extends RuntimeException
{
public static function between(string $periodA, string $periodB): self
{
return new self(sprintf(
'Les périodes "%s" et "%s" se chevauchent.',
$periodA,
$periodB,
));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\AcademicYear;
use App\Administration\Domain\Exception\InvalidPeriodDatesException;
use DateTimeImmutable;
/**
* Value Object représentant une période scolaire (trimestre ou semestre).
*
* Une période a un numéro de séquence, un libellé, et des dates de début/fin.
* La date de fin doit être strictement postérieure à la date de début.
*/
final readonly class AcademicPeriod
{
public function __construct(
public int $sequence,
public string $label,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
) {
if ($this->endDate <= $this->startDate) {
throw InvalidPeriodDatesException::endBeforeStart(
$this->label,
$this->startDate->format('Y-m-d'),
$this->endDate->format('Y-m-d'),
);
}
}
public function containsDate(DateTimeImmutable $date): bool
{
$d = $date->format('Y-m-d');
return $d >= $this->startDate->format('Y-m-d')
&& $d <= $this->endDate->format('Y-m-d');
}
public function daysRemaining(DateTimeImmutable $now): int
{
$today = $now->format('Y-m-d');
if ($today > $this->endDate->format('Y-m-d')) {
return 0;
}
if ($today < $this->startDate->format('Y-m-d')) {
return (int) $this->startDate->diff($this->endDate)->days;
}
return (int) $now->setTime(0, 0)->diff($this->endDate->setTime(0, 0))->days;
}
public function isPast(DateTimeImmutable $now): bool
{
return $now->format('Y-m-d') > $this->endDate->format('Y-m-d');
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\AcademicYear;
use DateTimeImmutable;
/**
* Factory pour générer les périodes par défaut selon le type choisi.
*
* Trimestres (collège/lycée) : Sept-Nov / Déc-Fév / Mars-Juin
* Semestres : Sept-Jan / Fév-Juin
*/
final class DefaultPeriods
{
/**
* @param int $startYear L'année de début (ex: 2024 pour 2024-2025)
*/
public static function forType(PeriodType $type, int $startYear): PeriodConfiguration
{
$endYear = $startYear + 1;
$lastDayOfFeb = (new DateTimeImmutable("{$endYear}-03-01"))->modify('-1 day');
$firstDayAfterFeb = $lastDayOfFeb->modify('+1 day');
return match ($type) {
PeriodType::TRIMESTER => new PeriodConfiguration($type, [
new AcademicPeriod(
sequence: 1,
label: 'T1',
startDate: new DateTimeImmutable("{$startYear}-09-01"),
endDate: new DateTimeImmutable("{$startYear}-11-30"),
),
new AcademicPeriod(
sequence: 2,
label: 'T2',
startDate: new DateTimeImmutable("{$startYear}-12-01"),
endDate: $lastDayOfFeb,
),
new AcademicPeriod(
sequence: 3,
label: 'T3',
startDate: $firstDayAfterFeb,
endDate: new DateTimeImmutable("{$endYear}-06-30"),
),
]),
PeriodType::SEMESTER => new PeriodConfiguration($type, [
new AcademicPeriod(
sequence: 1,
label: 'S1',
startDate: new DateTimeImmutable("{$startYear}-09-01"),
endDate: new DateTimeImmutable("{$endYear}-01-31"),
),
new AcademicPeriod(
sequence: 2,
label: 'S2',
startDate: new DateTimeImmutable("{$endYear}-02-01"),
endDate: new DateTimeImmutable("{$endYear}-06-30"),
),
]),
};
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\AcademicYear;
use App\Administration\Domain\Exception\InvalidPeriodCountException;
use App\Administration\Domain\Exception\PeriodsCoverageGapException;
use App\Administration\Domain\Exception\PeriodsOverlapException;
use function count;
use DateTimeImmutable;
use function usort;
/**
* Value Object encapsulant la configuration complète des périodes d'une année scolaire.
*
* Invariants :
* - Le nombre de périodes correspond au PeriodType (3 pour trimestres, 2 pour semestres)
* - Aucun chevauchement entre périodes
* - Les périodes sont contiguës (pas de trou)
*/
final readonly class PeriodConfiguration
{
/** @var AcademicPeriod[] */
public array $periods;
/**
* @param AcademicPeriod[] $periods
*/
public function __construct(
public PeriodType $type,
array $periods,
) {
if (count($periods) !== $this->type->expectedCount()) {
throw InvalidPeriodCountException::forType(
$this->type->value,
$this->type->expectedCount(),
count($periods),
);
}
$sorted = $periods;
usort($sorted, static fn (AcademicPeriod $a, AcademicPeriod $b): int => $a->startDate <=> $b->startDate);
self::validateNoOverlap($sorted);
self::validateContiguity($sorted);
$this->periods = $sorted;
}
public function currentPeriod(DateTimeImmutable $now): ?AcademicPeriod
{
foreach ($this->periods as $period) {
if ($period->containsDate($now)) {
return $period;
}
}
return null;
}
public function periodBySequence(int $sequence): ?AcademicPeriod
{
foreach ($this->periods as $period) {
if ($period->sequence === $sequence) {
return $period;
}
}
return null;
}
public function startDate(): DateTimeImmutable
{
return $this->periods[0]->startDate;
}
public function endDate(): DateTimeImmutable
{
return $this->periods[count($this->periods) - 1]->endDate;
}
/**
* @param AcademicPeriod[] $periods Sorted by startDate
*/
private static function validateNoOverlap(array $periods): void
{
for ($i = 1; $i < count($periods); ++$i) {
if ($periods[$i]->startDate <= $periods[$i - 1]->endDate) {
throw PeriodsOverlapException::between(
$periods[$i - 1]->label,
$periods[$i]->label,
);
}
}
}
/**
* @param AcademicPeriod[] $periods Sorted by startDate
*/
private static function validateContiguity(array $periods): void
{
for ($i = 1; $i < count($periods); ++$i) {
$previousEnd = $periods[$i - 1]->endDate;
$nextStart = $periods[$i]->startDate;
$dayAfterPreviousEnd = $previousEnd->modify('+1 day');
if ($dayAfterPreviousEnd->format('Y-m-d') !== $nextStart->format('Y-m-d')) {
throw PeriodsCoverageGapException::gapBetween(
$periods[$i - 1]->label,
$periods[$i]->label,
);
}
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\AcademicYear;
/**
* Type de découpage de l'année scolaire.
*/
enum PeriodType: string
{
case TRIMESTER = 'trimester';
case SEMESTER = 'semester';
/**
* Nombre de périodes attendues pour ce type.
*/
public function expectedCount(): int
{
return match ($this) {
self::TRIMESTER => 3,
self::SEMESTER => 2,
};
}
}

View File

@@ -88,8 +88,9 @@ final class Subject extends AggregateRoot
$this->recordEvent(new MatiereModifiee(
subjectId: $this->id,
tenantId: $this->tenantId,
ancienNom: $ancienNom,
nouveauNom: $nouveauNom,
champ: 'nom',
ancienneValeur: (string) $ancienNom,
nouvelleValeur: (string) $nouveauNom,
occurredOn: $at,
));
}
@@ -106,8 +107,18 @@ final class Subject extends AggregateRoot
return;
}
$ancienCode = $this->code;
$this->code = $nouveauCode;
$this->updatedAt = $at;
$this->recordEvent(new MatiereModifiee(
subjectId: $this->id,
tenantId: $this->tenantId,
champ: 'code',
ancienneValeur: (string) $ancienCode,
nouvelleValeur: (string) $nouveauCode,
occurredOn: $at,
));
}
/**
@@ -123,8 +134,18 @@ final class Subject extends AggregateRoot
return;
}
$ancienneCouleur = $this->color;
$this->color = $couleur;
$this->updatedAt = $at;
$this->recordEvent(new MatiereModifiee(
subjectId: $this->id,
tenantId: $this->tenantId,
champ: 'couleur',
ancienneValeur: $ancienneCouleur !== null ? (string) $ancienneCouleur : null,
nouvelleValeur: $couleur !== null ? (string) $couleur : null,
occurredOn: $at,
));
}
/**
@@ -136,8 +157,18 @@ final class Subject extends AggregateRoot
return;
}
$ancienneDescription = $this->description;
$this->description = $description;
$this->updatedAt = $at;
$this->recordEvent(new MatiereModifiee(
subjectId: $this->id,
tenantId: $this->tenantId,
champ: 'description',
ancienneValeur: $ancienneDescription,
nouvelleValeur: $description,
occurredOn: $at,
));
}
/**

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
interface PeriodConfigurationRepository
{
public function save(TenantId $tenantId, AcademicYearId $academicYearId, PeriodConfiguration $configuration): void;
public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration;
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsCommand;
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsHandler;
use App\Administration\Domain\Exception\PeriodesDejaConfigureesException;
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
use App\Administration\Infrastructure\Security\PeriodVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Processor API Platform pour configurer les périodes d'une année scolaire.
*
* @implements ProcessorInterface<PeriodResource, PeriodResource>
*/
final readonly class ConfigurePeriodsProcessor implements ProcessorInterface
{
public function __construct(
private ConfigurePeriodsHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
private Clock $clock,
) {
}
/**
* @param PeriodResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): PeriodResource
{
if (!$this->authorizationChecker->isGranted(PeriodVoter::CONFIGURE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer les périodes.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
try {
$startYear = $data->startYear
?? $this->academicYearResolver->resolveStartYear($rawAcademicYearId)
?? (int) $this->clock->now()->format('Y');
$command = new ConfigurePeriodsCommand(
tenantId: $tenantId,
academicYearId: $academicYearId,
periodType: $data->periodType ?? 'trimester',
startYear: $startYear,
);
$config = ($this->handler)($command);
$resource = new PeriodResource();
$resource->academicYearId = $academicYearId;
$resource->type = $config->type->value;
$resource->periods = [];
foreach ($config->periods as $period) {
$item = new PeriodItem();
$item->sequence = $period->sequence;
$item->label = $period->label;
$item->startDate = $period->startDate->format('Y-m-d');
$item->endDate = $period->endDate->format('Y-m-d');
$resource->periods[] = $item;
}
return $resource;
} catch (PeriodesDejaConfigureesException $e) {
throw new ConflictHttpException($e->getMessage());
}
}
}

View File

@@ -73,18 +73,7 @@ final readonly class CreateSubjectProcessor implements ProcessorInterface
$this->eventBus->dispatch($event);
}
// Return the created resource
$resource = new SubjectResource();
$resource->id = (string) $subject->id;
$resource->name = (string) $subject->name;
$resource->code = (string) $subject->code;
$resource->color = $subject->color !== null ? (string) $subject->color : null;
$resource->description = $subject->description;
$resource->status = $subject->status->value;
$resource->createdAt = $subject->createdAt;
$resource->updatedAt = $subject->updatedAt;
return $resource;
return SubjectResource::fromDomain($subject);
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (SubjectDejaExistanteException $e) {

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodCommand;
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodHandler;
use App\Administration\Domain\Exception\InvalidPeriodDatesException;
use App\Administration\Domain\Exception\PeriodeAvecNotesException;
use App\Administration\Domain\Exception\PeriodeNonTrouveeException;
use App\Administration\Domain\Exception\PeriodesNonConfigureesException;
use App\Administration\Domain\Exception\PeriodsCoverageGapException;
use App\Administration\Domain\Exception\PeriodsOverlapException;
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
use App\Administration\Infrastructure\Security\PeriodVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Processor API Platform pour modifier les dates d'une période.
*
* @implements ProcessorInterface<PeriodResource, PeriodResource>
*/
final readonly class UpdatePeriodProcessor implements ProcessorInterface
{
public function __construct(
private UpdatePeriodHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/**
* @param PeriodResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): PeriodResource
{
if (!$this->authorizationChecker->isGranted(PeriodVoter::CONFIGURE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier les périodes.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
/** @var int|string $sequence */
$sequence = $uriVariables['sequence'];
$startDate = $data->startDate;
$endDate = $data->endDate;
if ($startDate === null || $startDate === '' || $endDate === null || $endDate === '') {
throw new BadRequestHttpException('Les dates de début et de fin sont obligatoires.');
}
try {
$command = new UpdatePeriodCommand(
tenantId: $tenantId,
academicYearId: $academicYearId,
sequence: (int) $sequence,
startDate: $startDate,
endDate: $endDate,
confirmImpact: $data->confirmImpact ?? false,
);
$config = ($this->handler)($command);
$resource = new PeriodResource();
$resource->academicYearId = $academicYearId;
$resource->type = $config->type->value;
$resource->periods = [];
foreach ($config->periods as $period) {
$item = new PeriodItem();
$item->sequence = $period->sequence;
$item->label = $period->label;
$item->startDate = $period->startDate->format('Y-m-d');
$item->endDate = $period->endDate->format('Y-m-d');
$resource->periods[] = $item;
}
return $resource;
} catch (PeriodesNonConfigureesException|PeriodeNonTrouveeException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (PeriodeAvecNotesException $e) {
throw new ConflictHttpException($e->getMessage());
} catch (InvalidPeriodDatesException|PeriodsOverlapException|PeriodsCoverageGapException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -86,18 +86,7 @@ final readonly class UpdateSubjectProcessor implements ProcessorInterface
$this->eventBus->dispatch($event);
}
// Return the updated resource
$resource = new SubjectResource();
$resource->id = (string) $subject->id;
$resource->name = (string) $subject->name;
$resource->code = (string) $subject->code;
$resource->color = $subject->color !== null ? (string) $subject->color : null;
$resource->description = $subject->description;
$resource->status = $subject->status->value;
$resource->createdAt = $subject->createdAt;
$resource->updatedAt = $subject->updatedAt;
return $resource;
return SubjectResource::fromDomain($subject);
} catch (SubjectNotFoundException|InvalidUuidStringException) {
throw new NotFoundHttpException('Matière non trouvée.');
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetPeriods\GetPeriodsHandler;
use App\Administration\Application\Query\GetPeriods\GetPeriodsQuery;
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
use App\Administration\Infrastructure\Security\PeriodVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la configuration des périodes.
*
* @implements ProviderInterface<PeriodResource>
*/
final readonly class PeriodsProvider implements ProviderInterface
{
public function __construct(
private GetPeriodsHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?PeriodResource
{
if (!$this->authorizationChecker->isGranted(PeriodVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les périodes.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
$result = ($this->handler)(new GetPeriodsQuery(
tenantId: $tenantId,
academicYearId: $academicYearId,
));
if ($result === null) {
return null;
}
$resource = new PeriodResource();
$resource->academicYearId = $academicYearId;
$resource->type = $result->type;
$resource->periods = [];
foreach ($result->periods as $periodDto) {
$item = new PeriodItem();
$item->sequence = $periodDto->sequence;
$item->label = $periodDto->label;
$item->startDate = $periodDto->startDate;
$item->endDate = $periodDto->endDate;
$item->isCurrent = $periodDto->isCurrent;
$item->daysRemaining = $periodDto->daysRemaining;
$item->isPast = $periodDto->isPast;
$resource->periods[] = $item;
}
if ($result->currentPeriod !== null) {
$current = new PeriodItem();
$current->sequence = $result->currentPeriod->sequence;
$current->label = $result->currentPeriod->label;
$current->startDate = $result->currentPeriod->startDate;
$current->endDate = $result->currentPeriod->endDate;
$current->isCurrent = true;
$current->daysRemaining = $result->currentPeriod->daysRemaining;
$resource->currentPeriod = $current;
}
return $resource;
}
}

View File

@@ -60,23 +60,6 @@ final readonly class SubjectCollectionProvider implements ProviderInterface
$subjectDtos = ($this->handler)($query);
return array_map(
static function ($dto) {
$resource = new SubjectResource();
$resource->id = $dto->id;
$resource->name = $dto->name;
$resource->code = $dto->code;
$resource->color = $dto->color;
$resource->description = $dto->description;
$resource->status = $dto->status;
$resource->createdAt = $dto->createdAt;
$resource->updatedAt = $dto->updatedAt;
$resource->teacherCount = $dto->teacherCount;
$resource->classCount = $dto->classCount;
return $resource;
},
$subjectDtos,
);
return array_map(SubjectResource::fromDto(...), $subjectDtos);
}
}

View File

@@ -62,16 +62,6 @@ final readonly class SubjectItemProvider implements ProviderInterface
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette matière.');
}
$resource = new SubjectResource();
$resource->id = (string) $subject->id;
$resource->name = (string) $subject->name;
$resource->code = (string) $subject->code;
$resource->color = $subject->color !== null ? (string) $subject->color : null;
$resource->description = $subject->description;
$resource->status = $subject->status->value;
$resource->createdAt = $subject->createdAt;
$resource->updatedAt = $subject->updatedAt;
return $resource;
return SubjectResource::fromDomain($subject);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
/**
* DTO pour une période individuelle dans la réponse API.
*
* Séparé de PeriodResource pour éviter qu'API Platform
* ne tente de générer un IRI pour chaque période.
*/
final class PeriodItem
{
public ?int $sequence = null;
public ?string $label = null;
public ?string $startDate = null;
public ?string $endDate = null;
public ?bool $isCurrent = null;
public ?int $daysRemaining = null;
public ?bool $isPast = null;
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Put;
use App\Administration\Infrastructure\Api\Processor\ConfigurePeriodsProcessor;
use App\Administration\Infrastructure\Api\Processor\UpdatePeriodProcessor;
use App\Administration\Infrastructure\Api\Provider\PeriodsProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la gestion des périodes scolaires.
*
* @see FR75 - Structurer l'année pour bulletins et moyennes
*/
#[ApiResource(
shortName: 'Period',
operations: [
new Get(
uriTemplate: '/academic-years/{academicYearId}/periods',
provider: PeriodsProvider::class,
name: 'get_periods',
),
new Put(
uriTemplate: '/academic-years/{academicYearId}/periods',
read: false,
processor: ConfigurePeriodsProcessor::class,
name: 'configure_periods',
),
new Patch(
uriTemplate: '/academic-years/{academicYearId}/periods/{sequence}',
read: false,
processor: UpdatePeriodProcessor::class,
name: 'update_period',
),
],
)]
final class PeriodResource
{
#[ApiProperty(identifier: true)]
public ?string $academicYearId = null;
public ?int $sequence = null;
#[Assert\Choice(choices: ['trimester', 'semester'], message: 'Le type de période doit être "trimester" ou "semester".')]
public ?string $periodType = null;
public ?int $startYear = null;
#[Assert\Date(message: 'La date de début doit être une date valide (YYYY-MM-DD).')]
public ?string $startDate = null;
#[Assert\Date(message: 'La date de fin doit être une date valide (YYYY-MM-DD).')]
public ?string $endDate = null;
public ?string $label = null;
public ?bool $isCurrent = null;
public ?int $daysRemaining = null;
public ?bool $isPast = null;
public ?bool $confirmImpact = null;
/** @var PeriodItem[]|null */
public ?array $periods = null;
public ?string $type = null;
public ?PeriodItem $currentPeriod = null;
}

View File

@@ -11,6 +11,8 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Administration\Application\Query\GetSubjects\SubjectDto;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Infrastructure\Api\Processor\CreateSubjectProcessor;
use App\Administration\Infrastructure\Api\Processor\DeleteSubjectProcessor;
use App\Administration\Infrastructure\Api\Processor\UpdateSubjectProcessor;
@@ -127,4 +129,42 @@ final class SubjectResource
*/
#[ApiProperty(readable: true, writable: false)]
public ?bool $hasGrades = null;
/**
* Crée un SubjectResource à partir du domain model.
*/
public static function fromDomain(Subject $subject): self
{
$resource = new self();
$resource->id = (string) $subject->id;
$resource->name = (string) $subject->name;
$resource->code = (string) $subject->code;
$resource->color = $subject->color !== null ? (string) $subject->color : null;
$resource->description = $subject->description;
$resource->status = $subject->status->value;
$resource->createdAt = $subject->createdAt;
$resource->updatedAt = $subject->updatedAt;
return $resource;
}
/**
* Crée un SubjectResource à partir d'un DTO de query.
*/
public static function fromDto(SubjectDto $dto): self
{
$resource = new self();
$resource->id = $dto->id;
$resource->name = $dto->name;
$resource->code = $dto->code;
$resource->color = $dto->color;
$resource->description = $dto->description;
$resource->status = $dto->status;
$resource->createdAt = $dto->createdAt;
$resource->updatedAt = $dto->updatedAt;
$resource->teacherCount = $dto->teacherCount;
$resource->classCount = $dto->classCount;
return $resource;
}
}

View File

@@ -28,28 +28,32 @@ final readonly class DoctrineClassRepository implements ClassRepository
#[Override]
public function save(SchoolClass $class): void
{
$data = [
'id' => (string) $class->id,
'tenant_id' => (string) $class->tenantId,
'school_id' => (string) $class->schoolId,
'academic_year_id' => (string) $class->academicYearId,
'name' => (string) $class->name,
'level' => $class->level?->value,
'capacity' => $class->capacity,
'status' => $class->status->value,
'description' => $class->description,
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
];
$exists = $this->findById($class->id) !== null;
if ($exists) {
$this->connection->update('school_classes', $data, ['id' => (string) $class->id]);
} else {
$this->connection->insert('school_classes', $data);
}
$this->connection->executeStatement(
'INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, description, created_at, updated_at, deleted_at)
VALUES (:id, :tenant_id, :school_id, :academic_year_id, :name, :level, :capacity, :status, :description, :created_at, :updated_at, :deleted_at)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
level = EXCLUDED.level,
capacity = EXCLUDED.capacity,
status = EXCLUDED.status,
description = EXCLUDED.description,
updated_at = EXCLUDED.updated_at,
deleted_at = EXCLUDED.deleted_at',
[
'id' => (string) $class->id,
'tenant_id' => (string) $class->tenantId,
'school_id' => (string) $class->schoolId,
'academic_year_id' => (string) $class->academicYearId,
'name' => (string) $class->name,
'level' => $class->level?->value,
'capacity' => $class->capacity,
'status' => $class->status->value,
'description' => $class->description,
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function count;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrinePeriodConfigurationRepository implements PeriodConfigurationRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(TenantId $tenantId, AcademicYearId $academicYearId, PeriodConfiguration $configuration): void
{
$this->connection->transactional(function () use ($tenantId, $academicYearId, $configuration): void {
$tenantIdStr = (string) $tenantId;
$academicYearIdStr = (string) $academicYearId;
$now = (new DateTimeImmutable())->format(DateTimeImmutable::ATOM);
$sequences = [];
foreach ($configuration->periods as $period) {
$sequences[] = $period->sequence;
$this->connection->executeStatement(
'INSERT INTO academic_periods (tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date, created_at, updated_at)
VALUES (:tenant_id, :academic_year_id, :period_type, :sequence, :label, :start_date, :end_date, :created_at, :updated_at)
ON CONFLICT (tenant_id, academic_year_id, sequence) DO UPDATE SET
period_type = EXCLUDED.period_type,
label = EXCLUDED.label,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
updated_at = EXCLUDED.updated_at',
[
'tenant_id' => $tenantIdStr,
'academic_year_id' => $academicYearIdStr,
'period_type' => $configuration->type->value,
'sequence' => $period->sequence,
'label' => $period->label,
'start_date' => $period->startDate->format('Y-m-d'),
'end_date' => $period->endDate->format('Y-m-d'),
'created_at' => $now,
'updated_at' => $now,
],
);
}
// Remove any extra periods (e.g. switching from trimester to semester would leave stale rows)
$this->connection->executeStatement(
'DELETE FROM academic_periods
WHERE tenant_id = :tenant_id
AND academic_year_id = :academic_year_id
AND sequence > :max_sequence',
[
'tenant_id' => $tenantIdStr,
'academic_year_id' => $academicYearIdStr,
'max_sequence' => count($configuration->periods),
],
);
});
}
#[Override]
public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM academic_periods
WHERE tenant_id = :tenant_id
AND academic_year_id = :academic_year_id
ORDER BY sequence ASC',
[
'tenant_id' => (string) $tenantId,
'academic_year_id' => (string) $academicYearId,
],
);
if (count($rows) === 0) {
return null;
}
/** @var string $periodType */
$periodType = $rows[0]['period_type'];
$periods = array_map(static function (array $row): AcademicPeriod {
/** @var int|string $sequence */
$sequence = $row['sequence'];
/** @var string $label */
$label = $row['label'];
/** @var string $startDate */
$startDate = $row['start_date'];
/** @var string $endDate */
$endDate = $row['end_date'];
return new AcademicPeriod(
sequence: (int) $sequence,
label: $label,
startDate: new DateTimeImmutable($startDate),
endDate: new DateTimeImmutable($endDate),
);
}, $rows);
return new PeriodConfiguration(PeriodType::from($periodType), $periods);
}
}

View File

@@ -28,27 +28,31 @@ final readonly class DoctrineSubjectRepository implements SubjectRepository
#[Override]
public function save(Subject $subject): void
{
$data = [
'id' => (string) $subject->id,
'tenant_id' => (string) $subject->tenantId,
'school_id' => (string) $subject->schoolId,
'name' => (string) $subject->name,
'code' => (string) $subject->code,
'color' => $subject->color !== null ? (string) $subject->color : null,
'status' => $subject->status->value,
'description' => $subject->description,
'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM),
'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM),
];
$exists = $this->findById($subject->id) !== null;
if ($exists) {
$this->connection->update('subjects', $data, ['id' => (string) $subject->id]);
} else {
$this->connection->insert('subjects', $data);
}
$this->connection->executeStatement(
'INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, description, created_at, updated_at, deleted_at)
VALUES (:id, :tenant_id, :school_id, :name, :code, :color, :status, :description, :created_at, :updated_at, :deleted_at)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
code = EXCLUDED.code,
color = EXCLUDED.color,
status = EXCLUDED.status,
description = EXCLUDED.description,
updated_at = EXCLUDED.updated_at,
deleted_at = EXCLUDED.deleted_at',
[
'id' => (string) $subject->id,
'tenant_id' => (string) $subject->tenantId,
'school_id' => (string) $subject->schoolId,
'name' => (string) $subject->name,
'code' => (string) $subject->code,
'color' => $subject->color !== null ? (string) $subject->color : null,
'status' => $subject->status->value,
'description' => $subject->description,
'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM),
'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
use App\Shared\Domain\Tenant\TenantId;
final class InMemoryPeriodConfigurationRepository implements PeriodConfigurationRepository
{
/** @var array<string, PeriodConfiguration> */
private array $configurations = [];
public function save(TenantId $tenantId, AcademicYearId $academicYearId, PeriodConfiguration $configuration): void
{
$this->configurations[$this->key($tenantId, $academicYearId)] = $configuration;
}
public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration
{
return $this->configurations[$this->key($tenantId, $academicYearId)] ?? null;
}
private function key(TenantId $tenantId, AcademicYearId $academicYearId): string
{
return (string) $tenantId . ':' . (string) $academicYearId;
}
}

View File

@@ -72,8 +72,8 @@ final class InMemorySubjectRepository implements SubjectRepository
): ?Subject {
$subject = $this->byTenantSchoolCode[$this->codeKey($code, $tenantId, $schoolId)] ?? null;
// Filtrer les matières archivées (comme Doctrine avec deleted_at IS NULL)
if ($subject !== null && $subject->deletedAt !== null) {
// Filtrer les matières archivées (cohérent avec findActiveByTenantAndSchool)
if ($subject !== null && $subject->status !== SubjectStatus::ACTIVE) {
return null;
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
use function in_array;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Voter pour les autorisations sur les périodes scolaires.
*
* Règles d'accès :
* - ADMIN et SUPER_ADMIN : accès complet (lecture + configuration)
* - Autres rôles : lecture seule
*
* @extends Voter<string, PeriodResource|null>
*/
final class PeriodVoter extends Voter
{
public const string VIEW = 'PERIOD_VIEW';
public const string CONFIGURE = 'PERIOD_CONFIGURE';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::CONFIGURE,
];
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true);
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
$roles = $user->getRoles();
return match ($attribute) {
self::VIEW => $this->canView($roles),
self::CONFIGURE => $this->canConfigure($roles),
default => false,
};
}
/**
* @param string[] $roles
*/
private function canView(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
Role::PROF->value,
Role::VIE_SCOLAIRE->value,
Role::SECRETARIAT->value,
]);
}
/**
* @param string[] $roles
*/
private function canConfigure(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/**
* @param string[] $userRoles
* @param string[] $allowedRoles
*/
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
{
foreach ($userRoles as $role) {
if (in_array($role, $allowedRoles, true)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Ramsey\Uuid\Uuid;
/**
* Résout les identifiants spéciaux 'current' et 'next' en UUID v5 déterministes
* basés sur le tenant et l'année scolaire.
*
* L'année scolaire est calculée selon le calendrier français :
* septembre → juin (ex: sept 2025 = année 2025-2026).
*/
final readonly class CurrentAcademicYearResolver
{
/** Namespace UUID v5 dédié aux années scolaires. */
private const string NAMESPACE = '6ba7b814-9dad-11d1-80b4-00c04fd430c8';
public function __construct(
private TenantContext $tenantContext,
private Clock $clock,
) {
}
/**
* @return string|null UUID résolu, ou null si l'identifiant est invalide
*/
public function resolve(string $academicYearId): ?string
{
if (Uuid::isValid($academicYearId)) {
return $academicYearId;
}
$startYear = $this->resolveStartYear($academicYearId);
if ($startYear === null) {
return null;
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$name = $tenantId . ':' . $startYear . '-' . ($startYear + 1);
return Uuid::uuid5(self::NAMESPACE, $name)->toString();
}
/**
* Résout l'année de début scolaire pour un identifiant spécial.
*
* @return int|null L'année de début (ex: 2025 pour 2025-2026), ou null si invalide
*/
public function resolveStartYear(string $academicYearId): ?int
{
$offset = match ($academicYearId) {
'previous' => -1,
'current' => 0,
'next' => 1,
default => null,
};
if ($offset === null) {
return null;
}
$now = $this->clock->now();
$month = (int) $now->format('n');
$year = (int) $now->format('Y');
return ($month >= 9 ? $year : $year - 1) + $offset;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use Override;
/**
* Implémentation par défaut qui retourne toujours false.
*
* Sera remplacée par une implémentation réelle quand le module Notes (Epic 6) existera.
*/
final class NoOpGradeExistenceChecker implements GradeExistenceChecker
{
#[Override]
public function hasGradesInPeriod(
TenantId $tenantId,
AcademicYearId $academicYearId,
int $periodSequence,
): bool {
return false;
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Administration\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* Tests for academic periods API endpoints.
*
* @see Story 2.3 - Gestion des périodes scolaires
*/
final class PeriodsEndpointsTest extends ApiTestCase
{
protected static ?bool $alwaysBootKernel = true;
// =========================================================================
// Security - Without tenant
// =========================================================================
#[Test]
public function getPeriodsReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('GET', '/api/academic-years/current/periods', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function configurePeriodsReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('PUT', '/api/academic-years/current/periods', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['periodType' => 'trimester', 'startYear' => 2025],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function updatePeriodReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('PATCH', '/api/academic-years/current/periods/1', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
'Content-Type' => 'application/merge-patch+json',
],
'json' => ['startDate' => '2025-09-02', 'endDate' => '2025-11-30'],
]);
self::assertResponseStatusCodeSame(404);
}
// =========================================================================
// Security - Without authentication (with tenant)
// =========================================================================
#[Test]
public function getPeriodsReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/periods', [
'headers' => [
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function configurePeriodsReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('PUT', 'http://ecole-alpha.classeo.local/api/academic-years/current/periods', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['periodType' => 'trimester', 'startYear' => 2025],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function updatePeriodReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('PATCH', 'http://ecole-alpha.classeo.local/api/academic-years/current/periods/1', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/merge-patch+json',
],
'json' => ['startDate' => '2025-09-02', 'endDate' => '2025-11-30'],
]);
self::assertResponseStatusCodeSame(401);
}
// =========================================================================
// Special identifiers - 'current', 'next', 'previous'
// =========================================================================
#[Test]
public function getPeriodsAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/periods', [
'headers' => ['Accept' => 'application/json'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getPeriodsAcceptsNextIdentifier(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/next/periods', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getPeriodsAcceptsPreviousIdentifier(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/previous/periods', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ConfigurePeriods;
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsCommand;
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsHandler;
use App\Administration\Domain\Exception\PeriodesDejaConfigureesException;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class ConfigurePeriodsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryPeriodConfigurationRepository $repository;
private ConfigurePeriodsHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryPeriodConfigurationRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-01-31 10:00:00');
}
};
$eventBus = new class implements MessageBusInterface {
public function dispatch(object $message, array $stamps = []): Envelope
{
return new Envelope($message);
}
};
$this->handler = new ConfigurePeriodsHandler($this->repository, $clock, $eventBus);
}
#[Test]
public function itConfiguresTrimesterPeriods(): void
{
$command = new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'trimester',
startYear: 2025,
);
$config = ($this->handler)($command);
self::assertSame(PeriodType::TRIMESTER, $config->type);
self::assertCount(3, $config->periods);
self::assertSame('T1', $config->periods[0]->label);
self::assertSame('T2', $config->periods[1]->label);
self::assertSame('T3', $config->periods[2]->label);
}
#[Test]
public function itConfiguresSemesterPeriods(): void
{
$command = new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'semester',
startYear: 2025,
);
$config = ($this->handler)($command);
self::assertSame(PeriodType::SEMESTER, $config->type);
self::assertCount(2, $config->periods);
self::assertSame('S1', $config->periods[0]->label);
self::assertSame('S2', $config->periods[1]->label);
}
#[Test]
public function itPersistsConfiguration(): void
{
$command = new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'trimester',
startYear: 2025,
);
($this->handler)($command);
$saved = $this->repository->findByAcademicYear(
\App\Shared\Domain\Tenant\TenantId::fromString(self::TENANT_ID),
\App\Administration\Domain\Model\SchoolClass\AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNotNull($saved);
self::assertSame(PeriodType::TRIMESTER, $saved->type);
}
#[Test]
public function itRejectsDoubleConfiguration(): void
{
$command = new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'trimester',
startYear: 2025,
);
($this->handler)($command);
$this->expectException(PeriodesDejaConfigureesException::class);
($this->handler)($command);
}
#[Test]
public function itAllowsDifferentTenantsToConfigureSameYear(): void
{
$otherTenantId = '550e8400-e29b-41d4-a716-446655440099';
($this->handler)(new ConfigurePeriodsCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'trimester',
startYear: 2025,
));
$config = ($this->handler)(new ConfigurePeriodsCommand(
tenantId: $otherTenantId,
academicYearId: self::ACADEMIC_YEAR_ID,
periodType: 'semester',
startYear: 2025,
));
self::assertSame(PeriodType::SEMESTER, $config->type);
}
}

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\UpdatePeriod;
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodCommand;
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodHandler;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Exception\PeriodeAvecNotesException;
use App\Administration\Domain\Exception\PeriodeNonTrouveeException;
use App\Administration\Domain\Exception\PeriodesNonConfigureesException;
use App\Administration\Domain\Exception\PeriodsOverlapException;
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\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class UpdatePeriodHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryPeriodConfigurationRepository $repository;
private UpdatePeriodHandler $handler;
private Clock $clock;
private MessageBusInterface $eventBus;
protected function setUp(): void
{
$this->repository = new InMemoryPeriodConfigurationRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2025-10-15 10:00:00');
}
};
$this->eventBus = new class implements MessageBusInterface {
public function dispatch(object $message, array $stamps = []): Envelope
{
return new Envelope($message);
}
};
$this->handler = new UpdatePeriodHandler($this->repository, new NoOpGradeExistenceChecker(), $this->clock, $this->eventBus);
}
#[Test]
public function itUpdatesPeriodDates(): void
{
$this->seedTrimesterConfig();
$command = new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-01',
endDate: '2025-12-15',
);
$this->expectException(PeriodsOverlapException::class);
($this->handler)($command);
}
#[Test]
public function itUpdatesValidPeriodDates(): void
{
$this->seedTrimesterConfig();
$command = new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-02',
endDate: '2025-11-30',
);
$config = ($this->handler)($command);
self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d'));
self::assertSame('2025-11-30', $config->periods[0]->endDate->format('Y-m-d'));
}
#[Test]
public function itRejectsWhenNoConfigurationExists(): void
{
$this->expectException(PeriodesNonConfigureesException::class);
($this->handler)(new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-01',
endDate: '2025-11-30',
));
}
#[Test]
public function itRejectsUnknownSequence(): void
{
$this->seedTrimesterConfig();
$this->expectException(PeriodeNonTrouveeException::class);
($this->handler)(new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 99,
startDate: '2025-09-01',
endDate: '2025-11-30',
));
}
#[Test]
public function itRejectsUpdateWhenPeriodHasGradesWithoutConfirmation(): void
{
$this->seedTrimesterConfig();
$gradeChecker = new class implements GradeExistenceChecker {
#[Override]
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
{
return true;
}
};
$handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus);
$this->expectException(PeriodeAvecNotesException::class);
$handler(new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-02',
endDate: '2025-11-30',
));
}
#[Test]
public function itAllowsUpdateWhenPeriodHasGradesWithConfirmation(): void
{
$this->seedTrimesterConfig();
$gradeChecker = new class implements GradeExistenceChecker {
#[Override]
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
{
return true;
}
};
$handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus);
$config = $handler(new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-02',
endDate: '2025-11-30',
confirmImpact: true,
));
self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d'));
}
private function seedTrimesterConfig(): void
{
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
$this->repository->save(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
$config,
);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetPeriods;
use App\Administration\Application\Query\GetPeriods\GetPeriodsHandler;
use App\Administration\Application\Query\GetPeriods\GetPeriodsQuery;
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\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetPeriodsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryPeriodConfigurationRepository $repository;
protected function setUp(): void
{
$this->repository = new InMemoryPeriodConfigurationRepository();
}
#[Test]
public function itReturnsNullWhenNoPeriodsConfigured(): void
{
$handler = $this->createHandler('2025-10-15');
$result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID));
self::assertNull($result);
}
#[Test]
public function itReturnsPeriodsWithCurrentPeriodInfo(): void
{
$this->seedTrimesterConfig();
$handler = $this->createHandler('2025-10-15');
$result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID));
self::assertNotNull($result);
self::assertSame('trimester', $result->type);
self::assertCount(3, $result->periods);
// T1 is current
self::assertNotNull($result->currentPeriod);
self::assertSame('T1', $result->currentPeriod->label);
self::assertTrue($result->currentPeriod->isCurrent);
self::assertSame(46, $result->currentPeriod->daysRemaining);
}
#[Test]
public function itReturnsNullCurrentPeriodWhenOutOfRange(): void
{
$this->seedTrimesterConfig();
$handler = $this->createHandler('2025-08-15');
$result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID));
self::assertNotNull($result);
self::assertNull($result->currentPeriod);
}
#[Test]
public function itMarksPastPeriods(): void
{
$this->seedTrimesterConfig();
$handler = $this->createHandler('2026-04-15');
$result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID));
self::assertNotNull($result);
self::assertTrue($result->periods[0]->isPast);
self::assertTrue($result->periods[1]->isPast);
self::assertFalse($result->periods[2]->isPast);
}
private function seedTrimesterConfig(): void
{
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
$this->repository->save(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
$config,
);
}
private function createHandler(string $dateString): GetPeriodsHandler
{
$clock = new class($dateString) implements Clock {
public function __construct(private readonly string $dateString)
{
}
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->dateString);
}
};
return new GetPeriodsHandler($this->repository, $clock);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\AcademicYear;
use App\Administration\Domain\Exception\InvalidPeriodDatesException;
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AcademicPeriodTest extends TestCase
{
#[Test]
public function itCreatesValidPeriod(): void
{
$period = new AcademicPeriod(
sequence: 1,
label: 'T1',
startDate: new DateTimeImmutable('2025-09-01'),
endDate: new DateTimeImmutable('2025-11-30'),
);
self::assertSame(1, $period->sequence);
self::assertSame('T1', $period->label);
self::assertSame('2025-09-01', $period->startDate->format('Y-m-d'));
self::assertSame('2025-11-30', $period->endDate->format('Y-m-d'));
}
#[Test]
public function itRejectsEndDateBeforeStartDate(): void
{
$this->expectException(InvalidPeriodDatesException::class);
new AcademicPeriod(
sequence: 1,
label: 'T1',
startDate: new DateTimeImmutable('2025-11-30'),
endDate: new DateTimeImmutable('2025-09-01'),
);
}
#[Test]
public function itRejectsEqualDates(): void
{
$this->expectException(InvalidPeriodDatesException::class);
new AcademicPeriod(
sequence: 1,
label: 'T1',
startDate: new DateTimeImmutable('2025-09-01'),
endDate: new DateTimeImmutable('2025-09-01'),
);
}
#[Test]
public function itDetectsDateWithinPeriod(): void
{
$period = new AcademicPeriod(
sequence: 1,
label: 'T1',
startDate: new DateTimeImmutable('2025-09-01'),
endDate: new DateTimeImmutable('2025-11-30'),
);
self::assertTrue($period->containsDate(new DateTimeImmutable('2025-10-15')));
self::assertTrue($period->containsDate(new DateTimeImmutable('2025-09-01')));
self::assertTrue($period->containsDate(new DateTimeImmutable('2025-11-30')));
self::assertFalse($period->containsDate(new DateTimeImmutable('2025-08-31')));
self::assertFalse($period->containsDate(new DateTimeImmutable('2025-12-01')));
}
#[Test]
public function itIncludesLastDayRegardlessOfTime(): void
{
$period = new AcademicPeriod(
sequence: 1,
label: 'T1',
startDate: new DateTimeImmutable('2025-09-01'),
endDate: new DateTimeImmutable('2025-11-30'),
);
// Last day at 15:00 must still be "within" the period
self::assertTrue($period->containsDate(new DateTimeImmutable('2025-11-30 15:00:00')));
self::assertFalse($period->isPast(new DateTimeImmutable('2025-11-30 23:59:59')));
self::assertTrue($period->isPast(new DateTimeImmutable('2025-12-01 00:00:01')));
// daysRemaining on last day should be 0 (same day)
self::assertSame(0, $period->daysRemaining(new DateTimeImmutable('2025-11-30 15:00:00')));
}
#[Test]
public function itCalculatesDaysRemaining(): void
{
$period = new AcademicPeriod(
sequence: 1,
label: 'T1',
startDate: new DateTimeImmutable('2025-09-01'),
endDate: new DateTimeImmutable('2025-11-30'),
);
// During the period
self::assertSame(30, $period->daysRemaining(new DateTimeImmutable('2025-10-31')));
// After the period
self::assertSame(0, $period->daysRemaining(new DateTimeImmutable('2025-12-01')));
// Before the period: returns total period length
self::assertSame(90, $period->daysRemaining(new DateTimeImmutable('2025-08-01')));
}
#[Test]
public function itDetectsPastPeriod(): void
{
$period = new AcademicPeriod(
sequence: 1,
label: 'T1',
startDate: new DateTimeImmutable('2025-09-01'),
endDate: new DateTimeImmutable('2025-11-30'),
);
self::assertTrue($period->isPast(new DateTimeImmutable('2025-12-01')));
self::assertFalse($period->isPast(new DateTimeImmutable('2025-11-30')));
self::assertFalse($period->isPast(new DateTimeImmutable('2025-10-15')));
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\AcademicYear;
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DefaultPeriodsTest extends TestCase
{
#[Test]
public function itGeneratesDefaultTremesters(): void
{
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
self::assertSame(PeriodType::TRIMESTER, $config->type);
self::assertCount(3, $config->periods);
self::assertSame('T1', $config->periods[0]->label);
self::assertSame('2025-09-01', $config->periods[0]->startDate->format('Y-m-d'));
self::assertSame('2025-11-30', $config->periods[0]->endDate->format('Y-m-d'));
self::assertSame('T2', $config->periods[1]->label);
self::assertSame('2025-12-01', $config->periods[1]->startDate->format('Y-m-d'));
self::assertSame('2026-02-28', $config->periods[1]->endDate->format('Y-m-d'));
self::assertSame('T3', $config->periods[2]->label);
self::assertSame('2026-03-01', $config->periods[2]->startDate->format('Y-m-d'));
self::assertSame('2026-06-30', $config->periods[2]->endDate->format('Y-m-d'));
}
#[Test]
public function itHandlesLeapYearForTrimesters(): void
{
// 2023-2024 : 2024 is a leap year, Feb has 29 days
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2023);
self::assertSame('2024-02-29', $config->periods[1]->endDate->format('Y-m-d'));
self::assertSame('2024-03-01', $config->periods[2]->startDate->format('Y-m-d'));
}
#[Test]
public function itHandlesNonLeapYearForTrimesters(): void
{
// 2024-2025 : 2025 is not a leap year
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2024);
self::assertSame('2025-02-28', $config->periods[1]->endDate->format('Y-m-d'));
self::assertSame('2025-03-01', $config->periods[2]->startDate->format('Y-m-d'));
}
#[Test]
public function itGeneratesDefaultSemesters(): void
{
$config = DefaultPeriods::forType(PeriodType::SEMESTER, 2025);
self::assertSame(PeriodType::SEMESTER, $config->type);
self::assertCount(2, $config->periods);
self::assertSame('S1', $config->periods[0]->label);
self::assertSame('2025-09-01', $config->periods[0]->startDate->format('Y-m-d'));
self::assertSame('2026-01-31', $config->periods[0]->endDate->format('Y-m-d'));
self::assertSame('S2', $config->periods[1]->label);
self::assertSame('2026-02-01', $config->periods[1]->startDate->format('Y-m-d'));
self::assertSame('2026-06-30', $config->periods[1]->endDate->format('Y-m-d'));
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\AcademicYear;
use App\Administration\Domain\Exception\InvalidPeriodCountException;
use App\Administration\Domain\Exception\PeriodsCoverageGapException;
use App\Administration\Domain\Exception\PeriodsOverlapException;
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PeriodConfigurationTest extends TestCase
{
#[Test]
public function itCreatesValidTrimesterConfiguration(): void
{
$config = $this->validTrimesterConfig();
self::assertSame(PeriodType::TRIMESTER, $config->type);
self::assertCount(3, $config->periods);
self::assertSame('2025-09-01', $config->startDate()->format('Y-m-d'));
self::assertSame('2026-06-30', $config->endDate()->format('Y-m-d'));
}
#[Test]
public function itCreatesValidSemesterConfiguration(): void
{
$config = new PeriodConfiguration(PeriodType::SEMESTER, [
new AcademicPeriod(1, 'S1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2026-01-31')),
new AcademicPeriod(2, 'S2', new DateTimeImmutable('2026-02-01'), new DateTimeImmutable('2026-06-30')),
]);
self::assertSame(PeriodType::SEMESTER, $config->type);
self::assertCount(2, $config->periods);
}
#[Test]
public function itRejectsWrongPeriodCountForTrimester(): void
{
$this->expectException(InvalidPeriodCountException::class);
new PeriodConfiguration(PeriodType::TRIMESTER, [
new AcademicPeriod(1, 'S1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2026-01-31')),
new AcademicPeriod(2, 'S2', new DateTimeImmutable('2026-02-01'), new DateTimeImmutable('2026-06-30')),
]);
}
#[Test]
public function itRejectsWrongPeriodCountForSemester(): void
{
$this->expectException(InvalidPeriodCountException::class);
new PeriodConfiguration(PeriodType::SEMESTER, [
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-30')),
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')),
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
]);
}
#[Test]
public function itRejectsOverlappingPeriods(): void
{
$this->expectException(PeriodsOverlapException::class);
new PeriodConfiguration(PeriodType::TRIMESTER, [
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-12-01')),
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-11-30'), new DateTimeImmutable('2026-02-28')),
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
]);
}
#[Test]
public function itRejectsCoverageGap(): void
{
$this->expectException(PeriodsCoverageGapException::class);
new PeriodConfiguration(PeriodType::TRIMESTER, [
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-28')),
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')),
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
]);
}
#[Test]
public function itSortsPeriodsByStartDate(): void
{
$config = new PeriodConfiguration(PeriodType::TRIMESTER, [
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-30')),
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')),
]);
self::assertSame('T1', $config->periods[0]->label);
self::assertSame('T2', $config->periods[1]->label);
self::assertSame('T3', $config->periods[2]->label);
}
#[Test]
public function itFindsCurrentPeriod(): void
{
$config = $this->validTrimesterConfig();
$current = $config->currentPeriod(new DateTimeImmutable('2025-10-15'));
self::assertNotNull($current);
self::assertSame('T1', $current->label);
$current = $config->currentPeriod(new DateTimeImmutable('2026-01-15'));
self::assertNotNull($current);
self::assertSame('T2', $current->label);
$current = $config->currentPeriod(new DateTimeImmutable('2026-05-01'));
self::assertNotNull($current);
self::assertSame('T3', $current->label);
}
#[Test]
public function itReturnsNullWhenNoCurrentPeriod(): void
{
$config = $this->validTrimesterConfig();
self::assertNull($config->currentPeriod(new DateTimeImmutable('2025-08-01')));
self::assertNull($config->currentPeriod(new DateTimeImmutable('2026-07-01')));
}
#[Test]
public function itFindsPeriodBySequence(): void
{
$config = $this->validTrimesterConfig();
$period = $config->periodBySequence(2);
self::assertNotNull($period);
self::assertSame('T2', $period->label);
self::assertNull($config->periodBySequence(4));
}
private function validTrimesterConfig(): PeriodConfiguration
{
return new PeriodConfiguration(PeriodType::TRIMESTER, [
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-30')),
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')),
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\AcademicYear;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PeriodTypeTest extends TestCase
{
#[Test]
public function trimesterExpectsThreePeriods(): void
{
self::assertSame(3, PeriodType::TRIMESTER->expectedCount());
}
#[Test]
public function semesterExpectsTwoPeriods(): void
{
self::assertSame(2, PeriodType::SEMESTER->expectedCount());
}
#[Test]
public function itHasCorrectValues(): void
{
self::assertSame('trimester', PeriodType::TRIMESTER->value);
self::assertSame('semester', PeriodType::SEMESTER->value);
}
}

View File

@@ -112,8 +112,9 @@ final class SubjectTest extends TestCase
$events = $subject->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
self::assertTrue($events[0]->ancienNom->equals($ancienNom));
self::assertTrue($events[0]->nouveauNom->equals($nouveauNom));
self::assertSame('nom', $events[0]->champ);
self::assertSame((string) $ancienNom, $events[0]->ancienneValeur);
self::assertSame((string) $nouveauNom, $events[0]->nouvelleValeur);
}
#[Test]
@@ -130,9 +131,10 @@ final class SubjectTest extends TestCase
}
#[Test]
public function changerCodeUpdatesCode(): void
public function changerCodeUpdatesCodeAndRecordsEvent(): void
{
$subject = $this->createSubject();
$subject->pullDomainEvents();
$at = new DateTimeImmutable('2026-02-01 10:00:00');
$nouveauCode = new SubjectCode('MATHS');
@@ -140,23 +142,33 @@ final class SubjectTest extends TestCase
self::assertTrue($subject->code->equals($nouveauCode));
self::assertEquals($at, $subject->updatedAt);
$events = $subject->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
self::assertSame('code', $events[0]->champ);
self::assertSame('MATH', $events[0]->ancienneValeur);
self::assertSame('MATHS', $events[0]->nouvelleValeur);
}
#[Test]
public function changerCodeWithSameCodeDoesNothing(): void
{
$subject = $this->createSubject();
$subject->pullDomainEvents();
$originalUpdatedAt = $subject->updatedAt;
$subject->changerCode(new SubjectCode('MATH'), new DateTimeImmutable('2026-02-01 10:00:00'));
self::assertEquals($originalUpdatedAt, $subject->updatedAt);
self::assertEmpty($subject->pullDomainEvents());
}
#[Test]
public function changerCouleurUpdatesColor(): void
public function changerCouleurUpdatesColorAndRecordsEvent(): void
{
$subject = $this->createSubject();
$subject->pullDomainEvents();
$at = new DateTimeImmutable('2026-02-01 10:00:00');
$nouvelleCouleur = new SubjectColor('#EF4444');
@@ -165,41 +177,66 @@ final class SubjectTest extends TestCase
self::assertNotNull($subject->color);
self::assertTrue($subject->color->equals($nouvelleCouleur));
self::assertEquals($at, $subject->updatedAt);
$events = $subject->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
self::assertSame('couleur', $events[0]->champ);
self::assertSame('#3B82F6', $events[0]->ancienneValeur);
self::assertSame('#EF4444', $events[0]->nouvelleValeur);
}
#[Test]
public function changerCouleurToNullRemovesColor(): void
public function changerCouleurToNullRemovesColorAndRecordsEvent(): void
{
$subject = $this->createSubject();
$subject->pullDomainEvents();
$at = new DateTimeImmutable('2026-02-01 10:00:00');
$subject->changerCouleur(null, $at);
self::assertNull($subject->color);
self::assertEquals($at, $subject->updatedAt);
$events = $subject->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
self::assertSame('couleur', $events[0]->champ);
self::assertSame('#3B82F6', $events[0]->ancienneValeur);
self::assertNull($events[0]->nouvelleValeur);
}
#[Test]
public function changerCouleurWithSameColorDoesNothing(): void
{
$subject = $this->createSubject();
$subject->pullDomainEvents();
$originalUpdatedAt = $subject->updatedAt;
$subject->changerCouleur(new SubjectColor('#3B82F6'), new DateTimeImmutable('2026-02-01 10:00:00'));
self::assertEquals($originalUpdatedAt, $subject->updatedAt);
self::assertEmpty($subject->pullDomainEvents());
}
#[Test]
public function decrireUpdatesDescription(): void
public function decrireUpdatesDescriptionAndRecordsEvent(): void
{
$subject = $this->createSubject();
$subject->pullDomainEvents();
$at = new DateTimeImmutable('2026-02-01 10:00:00');
$subject->decrire('Cours de mathématiques généralistes', $at);
self::assertSame('Cours de mathématiques généralistes', $subject->description);
self::assertEquals($at, $subject->updatedAt);
$events = $subject->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
self::assertSame('description', $events[0]->champ);
self::assertNull($events[0]->ancienneValeur);
self::assertSame('Cours de mathématiques généralistes', $events[0]->nouvelleValeur);
}
#[Test]

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

View File

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

View File

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