feat: Configurer les jours fériés et vacances du calendrier scolaire

Les administrateurs d'établissement avaient besoin de gérer le calendrier
scolaire (FR80) pour que l'EDT et les devoirs respectent automatiquement
les jours non travaillés. Sans cette configuration centralisée, chaque
module devait gérer indépendamment les contraintes de dates.

Le calendrier s'appuie sur l'API data.education.gouv.fr pour importer
les vacances officielles par zone (A/B/C) et calcule les 11 jours fériés
français (dont les fêtes mobiles liées à Pâques). Les enseignants sont
notifiés par email lors de l'ajout d'une journée pédagogique. Un query
IsSchoolDay et une validation des dates d'échéance de devoirs permettent
aux autres modules de s'intégrer sans couplage direct.
This commit is contained in:
2026-02-18 10:16:28 +01:00
parent 0951322d71
commit e06fd5424d
60 changed files with 7698 additions and 1 deletions

View File

@@ -176,6 +176,17 @@ services:
App\SuperAdmin\Domain\Repository\EstablishmentRepository:
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository
# School Calendar Repository (Story 2.11 - Calendrier scolaire)
App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository
App\Administration\Application\Port\OfficialCalendarProvider:
alias: App\Administration\Infrastructure\Service\JsonOfficialCalendarProvider
App\Administration\Infrastructure\Service\JsonOfficialCalendarProvider:
arguments:
$dataDirectory: '%kernel.project_dir%/var/data/calendar'
# Student Guardian Repository (Story 2.7 - Liaison parents-enfants)
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
arguments:

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260217093243 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create school_calendar_entries table for school calendar management';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE school_calendar_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
academic_year_id UUID NOT NULL,
entry_type VARCHAR(30) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
label VARCHAR(100) NOT NULL,
description TEXT,
zone VARCHAR(1),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT valid_dates CHECK (end_date >= start_date)
)
SQL);
$this->addSql('CREATE INDEX idx_calendar_tenant ON school_calendar_entries(tenant_id)');
$this->addSql('CREATE INDEX idx_calendar_year ON school_calendar_entries(academic_year_id)');
$this->addSql('CREATE INDEX idx_calendar_dates ON school_calendar_entries(start_date, end_date)');
$this->addSql('CREATE INDEX idx_calendar_type ON school_calendar_entries(entry_type)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE school_calendar_entries');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260217231503 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace separate tenant/year indexes with a composite index for school_calendar_entries';
}
public function up(Schema $schema): void
{
$this->addSql('DROP INDEX idx_calendar_tenant');
$this->addSql('DROP INDEX idx_calendar_year');
$this->addSql('CREATE INDEX idx_calendar_tenant_year ON school_calendar_entries(tenant_id, academic_year_id)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_calendar_tenant_year');
$this->addSql('CREATE INDEX idx_calendar_tenant ON school_calendar_entries(tenant_id)');
$this->addSql('CREATE INDEX idx_calendar_year ON school_calendar_entries(academic_year_id)');
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AddPedagogicalDay;
/**
* Command pour ajouter une journée pédagogique au calendrier scolaire.
*/
final readonly class AddPedagogicalDayCommand
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public string $date,
public string $label,
public ?string $description = null,
) {
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AddPedagogicalDay;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use function preg_match;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Ajoute une journée pédagogique au calendrier et déclenche la notification enseignants.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class AddPedagogicalDayHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private Clock $clock,
) {
}
public function __invoke(AddPedagogicalDayCommand $command): SchoolCalendar
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $command->date) !== 1) {
throw new InvalidArgumentException('La date doit être au format YYYY-MM-DD.');
}
[$y, $m, $d] = explode('-', $command->date);
if (!checkdate((int) $m, (int) $d, (int) $y)) {
throw new InvalidArgumentException('La date n\'existe pas dans le calendrier.');
}
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$date = new DateTimeImmutable($command->date);
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId)
?? SchoolCalendar::initialiser($tenantId, $academicYearId);
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::PEDAGOGICAL_DAY,
startDate: $date,
endDate: $date,
label: $command->label,
description: $command->description,
);
$calendar->ajouterJourneePedagogique($entry, $this->clock->now());
$this->calendarRepository->save($calendar);
return $calendar;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ConfigureCalendar;
/**
* Command pour configurer le calendrier scolaire avec une zone et les entrées officielles.
*/
final readonly class ConfigureCalendarCommand
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public string $zone,
public string $academicYear,
) {
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ConfigureCalendar;
use App\Administration\Application\Port\OfficialCalendarProvider;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Configure le calendrier scolaire en important les données officielles pour une zone.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ConfigureCalendarHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private OfficialCalendarProvider $calendarProvider,
private Clock $clock,
) {
}
public function __invoke(ConfigureCalendarCommand $command): SchoolCalendar
{
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$zone = SchoolZone::from($command->zone);
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId)
?? SchoolCalendar::initialiser($tenantId, $academicYearId);
$entries = $this->calendarProvider->toutesEntreesOfficielles($zone, $command->academicYear);
$calendar->configurer($zone, $entries, $this->clock->now());
$this->calendarRepository->save($calendar);
return $calendar;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
/**
* Port pour fournir les données officielles du calendrier scolaire.
*/
interface OfficialCalendarProvider
{
/**
* Retourne les jours fériés officiels pour une année académique.
*
* @return CalendarEntry[]
*/
public function joursFeries(string $academicYear): array;
/**
* Retourne les vacances scolaires pour une zone et une année académique.
*
* @return CalendarEntry[]
*/
public function vacancesParZone(SchoolZone $zone, string $academicYear): array;
/**
* Retourne toutes les entrées officielles (fériés + vacances) pour une zone.
*
* @return CalendarEntry[]
*/
public function toutesEntreesOfficielles(SchoolZone $zone, string $academicYear): array;
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\IsSchoolDay;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use function preg_match;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class IsSchoolDayHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
) {
}
public function __invoke(IsSchoolDayQuery $query): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $query->date) !== 1) {
throw new InvalidArgumentException('La date doit être au format YYYY-MM-DD.');
}
[$y, $m, $d] = explode('-', $query->date);
if (!checkdate((int) $m, (int) $d, (int) $y)) {
throw new InvalidArgumentException('La date n\'existe pas dans le calendrier.');
}
$date = new DateTimeImmutable($query->date);
// Weekend = pas un jour d'école
$dayOfWeek = (int) $date->format('N');
if ($dayOfWeek >= 6) {
return false;
}
$tenantId = TenantId::fromString($query->tenantId);
$academicYearId = AcademicYearId::fromString($query->academicYearId);
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId);
if ($calendar === null) {
// Pas de calendrier configuré : on considère que c'est un jour ouvré (lundi-vendredi)
return true;
}
return $calendar->estJourOuvre($date);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\IsSchoolDay;
final readonly class IsSchoolDayQuery
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public string $date,
) {
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\ValidateHomeworkDueDate;
final readonly class DueDateValidationResult
{
/**
* @param string[] $warnings
*/
public function __construct(
public bool $valid,
public ?string $reason = null,
public array $warnings = [],
) {
}
public static function ok(string ...$warnings): self
{
return new self(valid: true, warnings: $warnings);
}
public static function invalide(string $reason): self
{
return new self(valid: false, reason: $reason);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\ValidateHomeworkDueDate;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use function preg_match;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class ValidateHomeworkDueDateHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
) {
}
public function __invoke(ValidateHomeworkDueDateQuery $query): DueDateValidationResult
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $query->dueDate) !== 1) {
return DueDateValidationResult::invalide('La date doit être au format YYYY-MM-DD.');
}
[$y, $m, $d] = explode('-', $query->dueDate);
if (!checkdate((int) $m, (int) $d, (int) $y)) {
return DueDateValidationResult::invalide('La date n\'existe pas dans le calendrier.');
}
$dueDate = new DateTimeImmutable($query->dueDate);
// Weekend
$dayOfWeek = (int) $dueDate->format('N');
if ($dayOfWeek >= 6) {
return DueDateValidationResult::invalide(
"L'échéance ne peut pas être un weekend.",
);
}
$tenantId = TenantId::fromString($query->tenantId);
$academicYearId = AcademicYearId::fromString($query->academicYearId);
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId);
if ($calendar === null) {
return DueDateValidationResult::ok();
}
if (!$calendar->estJourOuvre($dueDate)) {
return DueDateValidationResult::invalide(
"L'échéance ne peut pas être un jour férié ou pendant les vacances.",
);
}
if ($calendar->estJourRetourVacances($dueDate)) {
return DueDateValidationResult::ok(
'Attention : cette date est le jour du retour de vacances.',
);
}
return DueDateValidationResult::ok();
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\ValidateHomeworkDueDate;
final readonly class ValidateHomeworkDueDateQuery
{
public function __construct(
public string $tenantId,
public string $academicYearId,
public string $dueDate,
) {
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
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\Uuid;
use Ramsey\Uuid\UuidInterface;
use function sprintf;
/**
* Événement émis lorsque le calendrier scolaire est configuré
* (zone sélectionnée, vacances importées).
*/
final readonly class CalendrierConfigure implements DomainEvent
{
public function __construct(
public TenantId $tenantId,
public AcademicYearId $academicYearId,
public SchoolZone $zone,
public int $nombreEntrees,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return Uuid::uuid5(
Uuid::NAMESPACE_DNS,
sprintf('school-calendar:%s:%s', $this->tenantId, $this->academicYearId),
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
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\Uuid;
use Ramsey\Uuid\UuidInterface;
use function sprintf;
/**
* Événement émis lorsqu'une journée pédagogique est ajoutée au calendrier.
*
* Déclenche la notification aux enseignants (AC5).
*/
final readonly class JourneePedagogiqueAjoutee implements DomainEvent
{
public function __construct(
public CalendarEntryId $entryId,
public TenantId $tenantId,
public AcademicYearId $academicYearId,
public DateTimeImmutable $date,
public string $label,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return Uuid::uuid5(
Uuid::NAMESPACE_DNS,
sprintf('school-calendar:%s:%s', $this->tenantId, $this->academicYearId),
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DateTimeImmutable;
use DomainException;
use function sprintf;
final class CalendrierDatesInvalidesException extends DomainException
{
public static function finAvantDebut(DateTimeImmutable $startDate, DateTimeImmutable $endDate): self
{
return new self(sprintf(
'La date de fin (%s) ne peut pas être antérieure à la date de début (%s).',
$endDate->format('Y-m-d'),
$startDate->format('Y-m-d'),
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use DomainException;
use function sprintf;
final class CalendrierEntreeNonTrouveeException extends DomainException
{
public static function avecId(CalendarEntryId $entryId): self
{
return new self(sprintf(
'L\'entrée de calendrier avec l\'ID "%s" n\'a pas été trouvée.',
(string) $entryId,
));
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function mb_substr;
use function preg_replace;
use function sprintf;
final class CalendrierLabelInvalideException extends DomainException
{
public static function pourLongueur(string $label, int $min, int $max): self
{
$sanitized = preg_replace('/[\x00-\x1f\x7f]/u', '', mb_substr($label, 0, 50));
return new self(sprintf(
'Le libellé "%s" doit contenir entre %d et %d caractères.',
$sanitized,
$min,
$max,
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DomainException;
use function sprintf;
final class CalendrierNonTrouveException extends DomainException
{
public static function pourTenantEtAnnee(TenantId $tenantId, AcademicYearId $academicYearId): self
{
return new self(sprintf(
'Aucun calendrier scolaire trouvé pour le tenant "%s" et l\'année académique "%s".',
(string) $tenantId,
(string) $academicYearId,
));
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Exception\CalendrierDatesInvalidesException;
use App\Administration\Domain\Exception\CalendrierLabelInvalideException;
use function assert;
use DateTimeImmutable;
use function mb_strlen;
use function trim;
/**
* Value Object représentant une entrée dans le calendrier scolaire.
*
* Chaque entrée couvre une période (jour unique ou plage) et indique
* un type (férié, vacances, journée pédagogique, etc.).
*/
final readonly class CalendarEntry
{
private const int MIN_LABEL_LENGTH = 2;
private const int MAX_LABEL_LENGTH = 100;
/** @var non-empty-string */
public string $label;
public function __construct(
public CalendarEntryId $id,
public CalendarEntryType $type,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
string $label,
public ?string $description = null,
) {
if ($this->endDate < $this->startDate) {
throw CalendrierDatesInvalidesException::finAvantDebut($this->startDate, $this->endDate);
}
$trimmed = trim($label);
$length = mb_strlen($trimmed);
if ($length < self::MIN_LABEL_LENGTH || $length > self::MAX_LABEL_LENGTH) {
throw CalendrierLabelInvalideException::pourLongueur($label, self::MIN_LABEL_LENGTH, self::MAX_LABEL_LENGTH);
}
assert($trimmed !== '');
$this->label = $trimmed;
}
/**
* Vérifie si cette entrée couvre une date donnée.
*/
public function couvre(DateTimeImmutable $date): bool
{
$dateStr = $date->format('Y-m-d');
return $dateStr >= $this->startDate->format('Y-m-d')
&& $dateStr <= $this->endDate->format('Y-m-d');
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
use App\Shared\Domain\EntityId;
final readonly class CalendarEntryId extends EntityId
{
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
/**
* Type d'entrée dans le calendrier scolaire.
*
* Chaque type a un impact sur la disponibilité des cours et devoirs.
*/
enum CalendarEntryType: string
{
case HOLIDAY = 'holiday';
case VACATION = 'vacation';
case PEDAGOGICAL_DAY = 'pedagogical';
case BRIDGE = 'bridge';
case EXCEPTIONAL_CLOSURE = 'closure';
public function label(): string
{
return match ($this) {
self::HOLIDAY => 'Jour férié',
self::VACATION => 'Vacances scolaires',
self::PEDAGOGICAL_DAY => 'Journée pédagogique',
self::BRIDGE => 'Pont',
self::EXCEPTIONAL_CLOSURE => 'Fermeture exceptionnelle',
};
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Event\CalendrierConfigure;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
use App\Administration\Domain\Exception\CalendrierEntreeNonTrouveeException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Aggregate Root représentant le calendrier scolaire d'un établissement.
*
* Un calendrier est identifié par (tenantId, academicYearId).
* Il contient les jours fériés, vacances et journées pédagogiques.
*
* @see FR80: Configurer jours fériés et vacances scolaires
*/
final class SchoolCalendar extends AggregateRoot
{
/** @var array<string, CalendarEntry> Indexé par CalendarEntryId */
private array $entries = [];
private function __construct(
public private(set) TenantId $tenantId,
public private(set) AcademicYearId $academicYearId,
public private(set) ?SchoolZone $zone,
) {
}
/**
* Initialise un nouveau calendrier scolaire pour un tenant et une année académique.
*/
public static function initialiser(
TenantId $tenantId,
AcademicYearId $academicYearId,
): self {
return new self(
tenantId: $tenantId,
academicYearId: $academicYearId,
zone: null,
);
}
/**
* Configure la zone scolaire et importe les entrées officielles.
*
* @param CalendarEntry[] $entries
*/
public function configurer(SchoolZone $zone, array $entries, DateTimeImmutable $at): void
{
$this->zone = $zone;
$this->entries = array_filter(
$this->entries,
static fn (CalendarEntry $e): bool => $e->type === CalendarEntryType::PEDAGOGICAL_DAY,
);
foreach ($entries as $entry) {
$this->entries[(string) $entry->id] = $entry;
}
$this->recordEvent(new CalendrierConfigure(
tenantId: $this->tenantId,
academicYearId: $this->academicYearId,
zone: $zone,
nombreEntrees: count($this->entries),
occurredOn: $at,
));
}
/**
* Configure uniquement la zone scolaire du calendrier.
*/
public function configurerZone(SchoolZone $zone): void
{
$this->zone = $zone;
}
/**
* Ajoute une entrée au calendrier.
*/
public function ajouterEntree(CalendarEntry $entry): void
{
$this->entries[(string) $entry->id] = $entry;
}
/**
* Ajoute une journée pédagogique et émet l'événement pour notifier les enseignants.
*/
public function ajouterJourneePedagogique(CalendarEntry $entry, DateTimeImmutable $at): void
{
if ($entry->type !== CalendarEntryType::PEDAGOGICAL_DAY) {
throw new InvalidArgumentException('L\'entrée doit être de type journée pédagogique.');
}
$this->entries[(string) $entry->id] = $entry;
$this->recordEvent(new JourneePedagogiqueAjoutee(
entryId: $entry->id,
tenantId: $this->tenantId,
academicYearId: $this->academicYearId,
date: $entry->startDate,
label: $entry->label,
occurredOn: $at,
));
}
/**
* Supprime une entrée du calendrier.
*
* @throws CalendrierEntreeNonTrouveeException
*/
public function supprimerEntree(CalendarEntryId $entryId): void
{
$key = (string) $entryId;
if (!isset($this->entries[$key])) {
throw CalendrierEntreeNonTrouveeException::avecId($entryId);
}
unset($this->entries[$key]);
}
/**
* Vide toutes les entrées du calendrier.
*/
public function viderEntrees(): void
{
$this->entries = [];
}
/**
* Vérifie si une date est un jour ouvré (pas un weekend, férié, ou vacances).
*/
public function estJourOuvre(DateTimeImmutable $date): bool
{
$dayOfWeek = (int) $date->format('N');
if ($dayOfWeek >= 6) {
return false;
}
foreach ($this->entries as $entry) {
if ($entry->couvre($date)) {
return false;
}
}
return true;
}
/**
* Trouve l'entrée calendrier couvrant une date donnée.
*/
public function trouverEntreePourDate(DateTimeImmutable $date): ?CalendarEntry
{
foreach ($this->entries as $entry) {
if ($entry->couvre($date)) {
return $entry;
}
}
return null;
}
/**
* Vérifie si une date tombe pendant les vacances scolaires.
*/
public function estEnVacances(DateTimeImmutable $date): bool
{
foreach ($this->entries as $entry) {
if ($entry->couvre($date) && $entry->type === CalendarEntryType::VACATION) {
return true;
}
}
return false;
}
/**
* Vérifie si une date est un jour de retour de vacances.
*/
public function estJourRetourVacances(DateTimeImmutable $date): bool
{
$veille = $date->modify('-1 day');
foreach ($this->entries as $entry) {
if ($entry->type === CalendarEntryType::VACATION
&& $veille->format('Y-m-d') === $entry->endDate->format('Y-m-d')
) {
return true;
}
}
return false;
}
/**
* @return CalendarEntry[]
*/
public function entries(): array
{
return array_values($this->entries);
}
/**
* Reconstitue un SchoolCalendar depuis le stockage.
*
* @param CalendarEntry[] $entries
*
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
TenantId $tenantId,
AcademicYearId $academicYearId,
?SchoolZone $zone,
array $entries,
): self {
$calendar = new self(
tenantId: $tenantId,
academicYearId: $academicYearId,
zone: $zone,
);
foreach ($entries as $entry) {
$calendar->entries[(string) $entry->id] = $entry;
}
return $calendar;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Exception\CalendrierNonTrouveException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
interface SchoolCalendarRepository
{
public function save(SchoolCalendar $calendar): void;
public function findByTenantAndYear(
TenantId $tenantId,
AcademicYearId $academicYearId,
): ?SchoolCalendar;
/**
* @throws CalendrierNonTrouveException
*/
public function getByTenantAndYear(
TenantId $tenantId,
AcademicYearId $academicYearId,
): SchoolCalendar;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\SchoolCalendar;
/**
* Zone scolaire française pour le calendrier des vacances.
*/
enum SchoolZone: string
{
case A = 'A';
case B = 'B';
case C = 'C';
/**
* @return non-empty-list<string>
*/
public function academies(): array
{
return match ($this) {
self::A => ['Besançon', 'Bordeaux', 'Clermont-Ferrand', 'Dijon', 'Grenoble', 'Limoges', 'Lyon', 'Poitiers'],
self::B => ['Aix-Marseille', 'Amiens', 'Lille', 'Nancy-Metz', 'Nantes', 'Nice', 'Orléans-Tours', 'Reims', 'Rennes', 'Rouen', 'Strasbourg'],
self::C => ['Créteil', 'Montpellier', 'Paris', 'Toulouse', 'Versailles'],
};
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\AddPedagogicalDay\AddPedagogicalDayCommand;
use App\Administration\Application\Command\AddPedagogicalDay\AddPedagogicalDayHandler;
use App\Administration\Domain\Exception\CalendrierDatesInvalidesException;
use App\Administration\Domain\Exception\CalendrierLabelInvalideException;
use App\Administration\Infrastructure\Api\Resource\CalendarResource;
use App\Administration\Infrastructure\Security\CalendarVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<CalendarResource, CalendarResource>
*/
final readonly class AddPedagogicalDayProcessor implements ProcessorInterface
{
public function __construct(
private AddPedagogicalDayHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
private MessageBusInterface $eventBus,
) {
}
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): CalendarResource
{
if (!$this->authorizationChecker->isGranted(CalendarVoter::CONFIGURE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à ajouter une journée pédagogique.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
if ($data->date === null || $data->label === null) {
throw new BadRequestHttpException('La date et le libellé sont requis.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$command = new AddPedagogicalDayCommand(
tenantId: $tenantId,
academicYearId: $academicYearId,
date: $data->date,
label: $data->label,
description: $data->description,
);
$calendar = ($this->handler)($command);
foreach ($calendar->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return CalendarResource::fromCalendar($calendar, $academicYearId);
} catch (InvalidArgumentException|CalendrierLabelInvalideException|CalendrierDatesInvalidesException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ConfigureCalendar\ConfigureCalendarCommand;
use App\Administration\Application\Command\ConfigureCalendar\ConfigureCalendarHandler;
use App\Administration\Infrastructure\Api\Resource\CalendarResource;
use App\Administration\Infrastructure\Security\CalendarVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use ValueError;
/**
* @implements ProcessorInterface<CalendarResource, CalendarResource>
*/
final readonly class ConfigureCalendarProcessor implements ProcessorInterface
{
public function __construct(
private ConfigureCalendarHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
private MessageBusInterface $eventBus,
) {
}
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): CalendarResource
{
if (!$this->authorizationChecker->isGranted(CalendarVoter::CONFIGURE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le calendrier.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$startYear = $this->academicYearResolver->resolveStartYear($rawAcademicYearId);
if ($startYear === null) {
throw new BadRequestHttpException(
'Impossible de déterminer l\'année scolaire pour l\'identifiant fourni. '
. 'Utilisez "previous", "current" ou "next".',
);
}
$academicYear = $startYear . '-' . ($startYear + 1);
try {
$command = new ConfigureCalendarCommand(
tenantId: $tenantId,
academicYearId: $academicYearId,
zone: $data->zone ?? $data->importZone ?? throw new BadRequestHttpException('La zone scolaire est requise.'),
academicYear: $academicYear,
);
$calendar = ($this->handler)($command);
foreach ($calendar->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return CalendarResource::fromCalendar($calendar, $academicYearId);
} catch (InvalidArgumentException|ValueError $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Api\Resource\CalendarResource;
use App\Administration\Infrastructure\Security\CalendarVoter;
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;
/**
* @implements ProviderInterface<CalendarResource>
*/
final readonly class CalendarProvider implements ProviderInterface
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?CalendarResource
{
if (!$this->authorizationChecker->isGranted(CalendarVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir le calendrier.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
$tenantId = $this->tenantContext->getCurrentTenantId();
$calendar = $this->calendarRepository->findByTenantAndYear(
$tenantId,
AcademicYearId::fromString($academicYearId),
);
if ($calendar === null) {
return null;
}
return CalendarResource::fromCalendar($calendar, $academicYearId);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\IsSchoolDay\IsSchoolDayHandler;
use App\Administration\Application\Query\IsSchoolDay\IsSchoolDayQuery;
use App\Administration\Infrastructure\Api\Resource\CalendarResource;
use App\Administration\Infrastructure\Security\CalendarVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use function preg_match;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<CalendarResource>
*/
final readonly class IsSchoolDayProvider implements ProviderInterface
{
public function __construct(
private IsSchoolDayHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): CalendarResource
{
if (!$this->authorizationChecker->isGranted(CalendarVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter le calendrier.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
/** @var string $date */
$date = $uriVariables['date'];
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
throw new BadRequestHttpException('La date doit être au format YYYY-MM-DD.');
}
[$y, $m, $d] = explode('-', $date);
if (!checkdate((int) $m, (int) $d, (int) $y)) {
throw new BadRequestHttpException('La date n\'existe pas dans le calendrier.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$isSchoolDay = ($this->handler)(new IsSchoolDayQuery(
tenantId: $tenantId,
academicYearId: $academicYearId,
date: $date,
));
$resource = new CalendarResource();
$resource->academicYearId = $academicYearId;
$resource->isSchoolDay = $isSchoolDay;
$resource->date = $date;
return $resource;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use Symfony\Component\Validator\Constraints as Assert;
final class CalendarEntryItem
{
public ?string $id = null;
#[Assert\NotBlank(message: "Le type d'entrée est requis.")]
#[Assert\Choice(choices: ['holiday', 'vacation', 'pedagogical', 'bridge', 'closure'], message: 'Type invalide.')]
public ?string $type = null;
#[Assert\NotBlank(message: 'La date de début est requise.')]
#[Assert\Date(message: 'La date de début doit être au format YYYY-MM-DD.')]
public ?string $startDate = null;
#[Assert\NotBlank(message: 'La date de fin est requise.')]
#[Assert\Date(message: 'La date de fin doit être au format YYYY-MM-DD.')]
public ?string $endDate = null;
#[Assert\NotBlank(message: 'Le libellé est requis.')]
#[Assert\Length(min: 2, max: 100, minMessage: 'Le libellé doit faire au moins 2 caractères.', maxMessage: 'Le libellé ne peut dépasser 100 caractères.')]
public ?string $label = null;
public ?string $description = null;
}

View File

@@ -0,0 +1,111 @@
<?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\Post;
use ApiPlatform\Metadata\Put;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Infrastructure\Api\Processor\AddPedagogicalDayProcessor;
use App\Administration\Infrastructure\Api\Processor\ConfigureCalendarProcessor;
use App\Administration\Infrastructure\Api\Provider\CalendarProvider;
use App\Administration\Infrastructure\Api\Provider\IsSchoolDayProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la gestion du calendrier scolaire.
*
* @see FR80 - Configurer jours fériés et vacances scolaires
*/
#[ApiResource(
shortName: 'Calendar',
operations: [
new Get(
uriTemplate: '/academic-years/{academicYearId}/calendar',
provider: CalendarProvider::class,
name: 'get_calendar',
),
new Put(
uriTemplate: '/academic-years/{academicYearId}/calendar',
read: false,
processor: ConfigureCalendarProcessor::class,
name: 'configure_calendar',
),
new Post(
uriTemplate: '/academic-years/{academicYearId}/calendar/import-official',
read: false,
processor: ConfigureCalendarProcessor::class,
name: 'import_official_holidays',
),
new Post(
uriTemplate: '/academic-years/{academicYearId}/calendar/pedagogical-day',
read: false,
processor: AddPedagogicalDayProcessor::class,
name: 'add_pedagogical_day',
),
new Get(
uriTemplate: '/academic-years/{academicYearId}/calendar/is-school-day/{date}',
provider: IsSchoolDayProvider::class,
name: 'is_school_day',
),
],
)]
final class CalendarResource
{
#[ApiProperty(identifier: true)]
public ?string $academicYearId = null;
#[Assert\Choice(choices: ['A', 'B', 'C'], message: 'La zone scolaire doit être A, B ou C.')]
public ?string $zone = null;
/** @var CalendarEntryItem[]|null */
public ?array $entries = null;
// Write-only for import-official
#[ApiProperty(readable: false)]
public ?string $importZone = null;
// Write-only for pedagogical-day
#[ApiProperty(readable: false)]
#[Assert\Date(message: 'La date doit être au format YYYY-MM-DD.')]
public ?string $date = null;
#[ApiProperty(readable: false)]
#[Assert\Length(min: 2, max: 100)]
public ?string $label = null;
#[ApiProperty(readable: false)]
public ?string $description = null;
// Read-only for is-school-day
#[ApiProperty(writable: false)]
public ?bool $isSchoolDay = null;
#[ApiProperty(writable: false)]
public ?string $reason = null;
public static function fromCalendar(SchoolCalendar $calendar, string $academicYearId): self
{
$resource = new self();
$resource->academicYearId = $academicYearId;
$resource->zone = $calendar->zone?->value;
$resource->entries = [];
foreach ($calendar->entries() as $entry) {
$item = new CalendarEntryItem();
$item->id = (string) $entry->id;
$item->type = $entry->type->value;
$item->startDate = $entry->startDate->format('Y-m-d');
$item->endDate = $entry->endDate->format('Y-m-d');
$item->label = $entry->label;
$item->description = $entry->description;
$resource->entries[] = $item;
}
return $resource;
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Repository\UserRepository;
use function array_filter;
use function count;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
use Throwable;
use Twig\Environment;
/**
* Notifie les enseignants par email lors de l'ajout d'une journée pédagogique.
*/
#[AsMessageHandler(bus: 'event.bus')]
final readonly class NotifyTeachersPedagogicalDayHandler
{
public function __construct(
private MailerInterface $mailer,
private Environment $twig,
private UserRepository $userRepository,
private LoggerInterface $logger,
private string $fromEmail = 'noreply@classeo.fr',
) {
}
public function __invoke(JourneePedagogiqueAjoutee $event): void
{
$allUsers = $this->userRepository->findAllByTenant($event->tenantId);
$teachers = array_filter(
$allUsers,
static fn ($user) => $user->aLeRole(Role::PROF),
);
if (count($teachers) === 0) {
$this->logger->info('Journée pédagogique ajoutée — aucun enseignant à notifier', [
'tenant_id' => (string) $event->tenantId,
'date' => $event->date->format('Y-m-d'),
'label' => $event->label,
]);
return;
}
$html = $this->twig->render('emails/pedagogical_day_notification.html.twig', [
'date' => $event->date->format('d/m/Y'),
'label' => $event->label,
]);
$sent = 0;
foreach ($teachers as $teacher) {
try {
$email = (new Email())
->from($this->fromEmail)
->to((string) $teacher->email)
->subject('Journée pédagogique — ' . $event->label)
->html($html);
$this->mailer->send($email);
++$sent;
} catch (Throwable $e) {
$this->logger->warning('Échec envoi notification journée pédagogique', [
'teacher_email' => (string) $teacher->email,
'error' => $e->getMessage(),
]);
}
}
$this->logger->info('Notifications journée pédagogique envoyées', [
'tenant_id' => (string) $event->tenantId,
'date' => $event->date->format('Y-m-d'),
'label' => $event->label,
'emails_sent' => $sent,
'teachers_total' => count($teachers),
]);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\CalendrierNonTrouveException;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineSchoolCalendarRepository implements SchoolCalendarRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(SchoolCalendar $calendar): void
{
$this->connection->transactional(function () use ($calendar): void {
$tenantId = (string) $calendar->tenantId;
$academicYearId = (string) $calendar->academicYearId;
$this->connection->executeStatement(
'DELETE FROM school_calendar_entries WHERE tenant_id = :tenant_id AND academic_year_id = :academic_year_id',
[
'tenant_id' => $tenantId,
'academic_year_id' => $academicYearId,
],
);
foreach ($calendar->entries() as $entry) {
$this->connection->executeStatement(
'INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, description, zone, created_at)
VALUES (:id, :tenant_id, :academic_year_id, :entry_type, :start_date, :end_date, :label, :description, :zone, :created_at)',
[
'id' => (string) $entry->id,
'tenant_id' => $tenantId,
'academic_year_id' => $academicYearId,
'entry_type' => $entry->type->value,
'start_date' => $entry->startDate->format('Y-m-d'),
'end_date' => $entry->endDate->format('Y-m-d'),
'label' => $entry->label,
'description' => $entry->description,
'zone' => $calendar->zone?->value,
'created_at' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM),
],
);
}
});
}
#[Override]
public function findByTenantAndYear(TenantId $tenantId, AcademicYearId $academicYearId): ?SchoolCalendar
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM school_calendar_entries
WHERE tenant_id = :tenant_id
AND academic_year_id = :academic_year_id
ORDER BY start_date ASC',
[
'tenant_id' => (string) $tenantId,
'academic_year_id' => (string) $academicYearId,
],
);
if ($rows === []) {
return null;
}
$entries = array_map(fn (array $row): CalendarEntry => $this->hydrateEntry($row), $rows);
/** @var string|null $zone */
$zone = $rows[0]['zone'];
return SchoolCalendar::reconstitute(
tenantId: $tenantId,
academicYearId: $academicYearId,
zone: $zone !== null ? SchoolZone::from($zone) : null,
entries: $entries,
);
}
#[Override]
public function getByTenantAndYear(TenantId $tenantId, AcademicYearId $academicYearId): SchoolCalendar
{
$calendar = $this->findByTenantAndYear($tenantId, $academicYearId);
if ($calendar === null) {
throw CalendrierNonTrouveException::pourTenantEtAnnee($tenantId, $academicYearId);
}
return $calendar;
}
/**
* @param array<string, mixed> $row
*/
private function hydrateEntry(array $row): CalendarEntry
{
/** @var string $id */
$id = $row['id'];
/** @var string $entryType */
$entryType = $row['entry_type'];
/** @var string $startDate */
$startDate = $row['start_date'];
/** @var string $endDate */
$endDate = $row['end_date'];
/** @var string $label */
$label = $row['label'];
/** @var string|null $description */
$description = $row['description'];
return new CalendarEntry(
id: CalendarEntryId::fromString($id),
type: CalendarEntryType::from($entryType),
startDate: new DateTimeImmutable($startDate),
endDate: new DateTimeImmutable($endDate),
label: $label,
description: $description,
);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\CalendrierNonTrouveException;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemorySchoolCalendarRepository implements SchoolCalendarRepository
{
/** @var array<string, SchoolCalendar> Indexé par tenant:year */
private array $calendars = [];
#[Override]
public function save(SchoolCalendar $calendar): void
{
$this->calendars[$this->key($calendar->tenantId, $calendar->academicYearId)] = $calendar;
}
#[Override]
public function findByTenantAndYear(TenantId $tenantId, AcademicYearId $academicYearId): ?SchoolCalendar
{
return $this->calendars[$this->key($tenantId, $academicYearId)] ?? null;
}
#[Override]
public function getByTenantAndYear(TenantId $tenantId, AcademicYearId $academicYearId): SchoolCalendar
{
$calendar = $this->findByTenantAndYear($tenantId, $academicYearId);
if ($calendar === null) {
throw CalendrierNonTrouveException::pourTenantEtAnnee($tenantId, $academicYearId);
}
return $calendar;
}
private function key(TenantId $tenantId, AcademicYearId $academicYearId): string
{
return $tenantId . ':' . $academicYearId;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
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 le calendrier scolaire.
*
* Règles d'accès :
* - ADMIN et SUPER_ADMIN : accès complet (lecture + configuration)
* - PROF, VIE_SCOLAIRE, SECRETARIAT : lecture seule
*
* @extends Voter<string, null>
*/
final class CalendarVoter extends Voter
{
public const string VIEW = 'CALENDAR_VIEW';
public const string CONFIGURE = 'CALENDAR_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,96 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use DateTimeImmutable;
use function sprintf;
/**
* Calcule les jours fériés français pour une année scolaire donnée.
*
* Couvre la période septembre → août (ex: "2024-2025" → sept 2024 à août 2025).
* Les dates mobiles (Pâques, Ascension, Pentecôte) sont calculées via l'algorithme de Butcher.
*/
final readonly class FrenchPublicHolidaysCalculator
{
/**
* @return list<array{date: string, label: string}>
*/
public function pourAnneeScolaire(string $academicYear): array
{
[$startYear, $endYear] = $this->parseAcademicYear($academicYear);
$holidays = [];
// Jours fériés de la première année (sept → déc)
$holidays[] = ['date' => "$startYear-11-01", 'label' => 'Toussaint'];
$holidays[] = ['date' => "$startYear-11-11", 'label' => 'Armistice'];
$holidays[] = ['date' => "$startYear-12-25", 'label' => 'Noël'];
// Jours fériés de la deuxième année (jan → août)
$holidays[] = ['date' => "$endYear-01-01", 'label' => "Jour de l'an"];
// Pâques et dates mobiles (basées sur l'année civile de Pâques = endYear)
$easter = $this->easterDate($endYear);
$holidays[] = [
'date' => $easter->modify('+1 day')->format('Y-m-d'),
'label' => 'Lundi de Pâques',
];
$holidays[] = ['date' => "$endYear-05-01", 'label' => 'Fête du travail'];
$holidays[] = ['date' => "$endYear-05-08", 'label' => 'Victoire 1945'];
$holidays[] = [
'date' => $easter->modify('+39 days')->format('Y-m-d'),
'label' => 'Ascension',
];
$holidays[] = [
'date' => $easter->modify('+50 days')->format('Y-m-d'),
'label' => 'Lundi de Pentecôte',
];
$holidays[] = ['date' => "$endYear-07-14", 'label' => 'Fête nationale'];
$holidays[] = ['date' => "$endYear-08-15", 'label' => 'Assomption'];
// Trier par date
usort($holidays, static fn (array $a, array $b): int => $a['date'] <=> $b['date']);
return $holidays;
}
/**
* @return array{int, int}
*/
private function parseAcademicYear(string $academicYear): array
{
$parts = explode('-', $academicYear);
return [(int) $parts[0], (int) $parts[1]];
}
/**
* Calcule la date de Pâques via l'algorithme de Butcher (Anonymous Gregorian).
*/
private function easterDate(int $year): DateTimeImmutable
{
$a = $year % 19;
$b = intdiv($year, 100);
$c = $year % 100;
$d = intdiv($b, 4);
$e = $b % 4;
$f = intdiv($b + 8, 25);
$g = intdiv($b - $f + 1, 3);
$h = (19 * $a + $b - $d - $g + 15) % 30;
$i = intdiv($c, 4);
$k = $c % 4;
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
$m = intdiv($a + 11 * $h + 22 * $l, 451);
$month = intdiv($h + $l - 7 * $m + 114, 31);
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
}
}

View File

@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use App\Administration\Application\Port\OfficialCalendarProvider;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use function array_merge;
use DateTimeImmutable;
use function dirname;
use function file_get_contents;
use function file_put_contents;
use InvalidArgumentException;
use function is_dir;
use function json_decode;
use function json_encode;
use const JSON_PRETTY_PRINT;
use const JSON_THROW_ON_ERROR;
use const JSON_UNESCAPED_UNICODE;
use const LOCK_EX;
use function mkdir;
use Override;
use function preg_match;
use Psr\Log\LoggerInterface;
use RuntimeException;
use function sprintf;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
/**
* Fournit les données officielles du calendrier scolaire depuis un fichier JSON local.
*
* Si le fichier n'existe pas pour l'année demandée, les données sont automatiquement
* récupérées depuis l'API data.education.gouv.fr puis sauvegardées en cache local.
*/
final readonly class JsonOfficialCalendarProvider implements OfficialCalendarProvider
{
private const string API_BASE_URL = 'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records';
public function __construct(
private string $dataDirectory,
private HttpClientInterface $httpClient,
private FrenchPublicHolidaysCalculator $holidaysCalculator,
private LoggerInterface $logger,
) {
}
#[Override]
public function joursFeries(string $academicYear): array
{
$data = $this->loadData($academicYear);
return array_map(
static fn (array $holiday): CalendarEntry => new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable($holiday['date']),
endDate: new DateTimeImmutable($holiday['date']),
label: $holiday['label'],
),
$data['holidays'],
);
}
#[Override]
public function vacancesParZone(SchoolZone $zone, string $academicYear): array
{
$data = $this->loadData($academicYear);
$vacations = $data['vacations'][$zone->value] ?? [];
return array_map(
static fn (array $vacation): CalendarEntry => new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable($vacation['start']),
endDate: new DateTimeImmutable($vacation['end']),
label: $vacation['label'],
),
$vacations,
);
}
#[Override]
public function toutesEntreesOfficielles(SchoolZone $zone, string $academicYear): array
{
return array_merge(
$this->joursFeries($academicYear),
$this->vacancesParZone($zone, $academicYear),
);
}
/**
* @return array{holidays: list<array{date: string, label: string}>, vacations: array<string, list<array{start: string, end: string, label: string}>>}
*/
private function loadData(string $academicYear): array
{
if (preg_match('/^\d{4}-\d{4}$/', $academicYear) !== 1) {
throw new InvalidArgumentException(sprintf(
'Format d\'année scolaire invalide : "%s". Attendu : "YYYY-YYYY".',
$academicYear,
));
}
$filePath = sprintf('%s/official-holidays-%s.json', $this->dataDirectory, $academicYear);
$content = @file_get_contents($filePath);
if ($content === false) {
$this->fetchAndSave($academicYear, $filePath);
$content = file_get_contents($filePath);
if ($content === false) {
throw new RuntimeException(sprintf(
'Impossible de lire le fichier calendrier : %s',
$filePath,
));
}
}
/** @var array{holidays: list<array{date: string, label: string}>, vacations: array<string, list<array{start: string, end: string, label: string}>>} $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
return $data;
}
private function fetchAndSave(string $academicYear, string $filePath): void
{
$this->logger->info('Fichier calendrier absent, récupération depuis l\'API gouv.fr', [
'academic_year' => $academicYear,
]);
try {
$vacations = $this->fetchVacationsFromApi($academicYear);
} catch (Throwable $e) {
throw new RuntimeException(sprintf(
'Impossible de récupérer le calendrier %s depuis l\'API : %s',
$academicYear,
$e->getMessage(),
), previous: $e);
}
$holidays = $this->holidaysCalculator->pourAnneeScolaire($academicYear);
$data = [
'academic_year' => $academicYear,
'holidays' => $holidays,
'vacations' => $vacations,
];
$directory = dirname($filePath);
if (!is_dir($directory)) {
mkdir($directory, 0o755, true);
}
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
file_put_contents($filePath, $json, LOCK_EX);
$this->logger->info('Calendrier {year} sauvegardé', [
'year' => $academicYear,
'path' => $filePath,
]);
}
/**
* @return array<string, list<array{start: string, end: string, label: string}>>
*/
private function fetchVacationsFromApi(string $academicYear): array
{
$where = sprintf(
'annee_scolaire="%s" AND (zones="Zone A" OR zones="Zone B" OR zones="Zone C")',
$academicYear,
);
$response = $this->httpClient->request('GET', self::API_BASE_URL, [
'query' => [
'where' => $where,
'select' => 'description,start_date,end_date,zones',
'limit' => 100,
],
'timeout' => 10,
]);
/** @var array{results: list<array{description: string, start_date: string, end_date: string, zones: string}>} $data */
$data = $response->toArray();
// Grouper par zone et dédupliquer
$vacationsByZone = ['A' => [], 'B' => [], 'C' => []];
$seen = [];
foreach ($data['results'] as $record) {
$zone = match ($record['zones']) {
'Zone A' => 'A',
'Zone B' => 'B',
'Zone C' => 'C',
default => null,
};
if ($zone === null) {
continue;
}
// Les dates API sont en ISO 8601 (ex: "2024-12-20T23:00:00+00:00")
// start_date utilise la convention "veille à 23h UTC" → +1 jour pour obtenir le 1er jour de vacances
// end_date représente déjà le dernier jour de vacances (pas de décalage)
$startDate = (new DateTimeImmutable($record['start_date']))->modify('+1 day')->format('Y-m-d');
$endDate = (new DateTimeImmutable($record['end_date']))->format('Y-m-d');
$label = $record['description'];
$key = "$zone|$label|$startDate|$endDate";
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$vacationsByZone[$zone][] = [
'start' => $startDate,
'end' => $endDate,
'label' => $label,
];
}
// Trier chaque zone par date de début
foreach ($vacationsByZone as &$vacations) {
usort($vacations, static fn (array $a, array $b): int => $a['start'] <=> $b['start']);
}
return $vacationsByZone;
}
}

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Journée pédagogique - Classeo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
padding: 20px 0;
border-bottom: 2px solid #4f46e5;
}
.header h1 {
color: #4f46e5;
margin: 0;
font-size: 28px;
}
.content {
padding: 30px 0;
}
.info-icon {
text-align: center;
padding: 20px;
}
.info-icon span {
display: inline-block;
width: 60px;
height: 60px;
background-color: #f59e0b;
border-radius: 50%;
line-height: 60px;
color: white;
font-size: 30px;
}
.info-box {
background-color: #f3f4f6;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.info-box p {
margin: 5px 0;
}
.footer {
text-align: center;
padding: 20px 0;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 14px;
}
</style>
</head>
<body>
<div class="header">
<h1>Classeo</h1>
</div>
<div class="content">
<div class="info-icon">
<span>!</span>
</div>
<h2 style="text-align: center;">Journée pédagogique programmée</h2>
<p>Bonjour,</p>
<p>Une journée pédagogique a été ajoutée au calendrier scolaire.</p>
<div class="info-box">
<p><strong>Date :</strong> {{ date }}</p>
<p><strong>Libellé :</strong> {{ label }}</p>
</div>
<p>Les cours ne seront pas assurés ce jour-là. Veuillez en tenir compte dans votre planification.</p>
</div>
<div class="footer">
<p>Cet email a été envoyé automatiquement par Classeo.</p>
<p>Pour toute question, contactez la direction de votre établissement.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,750 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Administration\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
/**
* Tests for school calendar API endpoints.
*
* @see Story 2.11 - Configuration Calendrier Scolaire
*/
final class CalendarEndpointsTest extends ApiTestCase
{
protected static ?bool $alwaysBootKernel = true;
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string ACADEMIC_YEAR_ID = '11111111-1111-1111-1111-111111111111';
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440000';
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api/academic-years/11111111-1111-1111-1111-111111111111';
/** URL with symbolic 'current' ID — required for configure/import (year resolution). */
private const string CONFIGURE_URL = 'http://ecole-alpha.classeo.local/api/academic-years/current';
protected function tearDown(): void
{
$container = static::getContainer();
/** @var Connection $connection */
$connection = $container->get(Connection::class);
$connection->executeStatement(
'DELETE FROM school_calendar_entries WHERE tenant_id = :tenant_id',
['tenant_id' => self::TENANT_ID],
);
parent::tearDown();
}
// =========================================================================
// Security - Without tenant
// =========================================================================
#[Test]
public function getCalendarReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('GET', '/api/academic-years/current/calendar', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function configureCalendarReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('PUT', '/api/academic-years/current/calendar', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function importOfficialHolidaysReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('POST', '/api/academic-years/current/calendar/import-official', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function addPedagogicalDayReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('POST', '/api/academic-years/current/calendar/pedagogical-day', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['date' => '2025-03-14', 'label' => 'Formation', 'description' => 'Formation continue'],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function isSchoolDayReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('GET', '/api/academic-years/current/calendar/is-school-day/2025-03-14', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(404);
}
// =========================================================================
// Security - Without authentication (with tenant)
// =========================================================================
#[Test]
public function getCalendarReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar', [
'headers' => [
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function configureCalendarReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('PUT', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function importOfficialHolidaysReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('POST', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/import-official', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function addPedagogicalDayReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('POST', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['date' => '2025-03-14', 'label' => 'Formation', 'description' => 'Formation continue'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function isSchoolDayReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/is-school-day/2025-03-14', [
'headers' => [
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(401);
}
// =========================================================================
// Special identifiers - 'current'
// =========================================================================
#[Test]
public function getCalendarAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar', [
'headers' => ['Accept' => 'application/json'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function configureCalendarAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('PUT', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function importOfficialHolidaysAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('POST', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/import-official', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function addPedagogicalDayAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('POST', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['date' => '2025-03-14', 'label' => 'Formation', 'description' => 'Formation continue'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function isSchoolDayAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/is-school-day/2025-03-14', [
'headers' => ['Accept' => 'application/json'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
// =========================================================================
// AC3 (P0) - is-school-day with data
// =========================================================================
#[Test]
public function isSchoolDayReturnsFalseForHoliday(): void
{
$this->persistCalendar([
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-12-25'),
endDate: new DateTimeImmutable('2025-12-25'),
label: 'Noël',
),
]);
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-12-25', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertFalse($data['isSchoolDay']);
}
#[Test]
public function isSchoolDayReturnsFalseForVacationDay(): void
{
$this->persistCalendar([
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-02-15'),
endDate: new DateTimeImmutable('2025-03-02'),
label: 'Vacances d\'hiver',
),
]);
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-02-20', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertFalse($data['isSchoolDay']);
}
#[Test]
public function isSchoolDayReturnsFalseForPedagogicalDay(): void
{
$this->persistCalendar([
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::PEDAGOGICAL_DAY,
startDate: new DateTimeImmutable('2025-03-14'),
endDate: new DateTimeImmutable('2025-03-14'),
label: 'Formation continue',
),
]);
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-03-14', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertFalse($data['isSchoolDay']);
}
#[Test]
public function isSchoolDayReturnsTrueForNormalWeekday(): void
{
$this->persistCalendar();
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
// 2025-03-10 is a Monday
$response = $client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-03-10', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertTrue($data['isSchoolDay']);
}
#[Test]
public function isSchoolDayReturns403ForParent(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PARENT']);
$client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-03-10', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// AC1 - GET calendar with data
// =========================================================================
#[Test]
public function getCalendarReturnsDataForAdmin(): void
{
$this->persistCalendar(
[
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-12-25'),
endDate: new DateTimeImmutable('2025-12-25'),
label: 'Noël',
),
],
SchoolZone::A,
);
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('GET', self::BASE_URL . '/calendar', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('A', $data['zone']);
self::assertNotEmpty($data['entries']);
}
#[Test]
public function getCalendarReturns200ForProf(): void
{
$this->persistCalendar(
[
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-12-25'),
endDate: new DateTimeImmutable('2025-12-25'),
label: 'Noël',
),
],
SchoolZone::A,
);
$client = $this->createAuthenticatedClient(['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/calendar', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
}
#[Test]
public function getCalendarReturns403ForParent(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PARENT']);
$client->request('GET', self::BASE_URL . '/calendar', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// AC2 - Configure + Import
// =========================================================================
#[Test]
public function configureCalendarReturns200ForAdmin(): void
{
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('PUT', self::CONFIGURE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('A', $data['zone']);
}
#[Test]
public function configureCalendarReturns403ForProf(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PROF']);
$client->request('PUT', self::BASE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function importOfficialHolidaysReturns200ForAdmin(): void
{
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('POST', self::CONFIGURE_URL . '/calendar/import-official', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertNotEmpty($data['entries']);
}
#[Test]
public function importOfficialHolidaysReturns403ForProf(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PROF']);
$client->request('POST', self::BASE_URL . '/calendar/import-official', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// AC2 - Ajustement post-import
// =========================================================================
#[Test]
public function reconfigureCalendarChangesZoneAndEntries(): void
{
// Configure zone A
$clientA = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$responseA = $clientA->request('PUT', self::CONFIGURE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseIsSuccessful();
$dataA = $responseA->toArray();
self::assertSame('A', $dataA['zone']);
self::assertNotEmpty($dataA['entries']);
// Reconfigure zone B (new client — kernel reboots between requests)
$clientB = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$responseB = $clientB->request('PUT', self::CONFIGURE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'B'],
]);
self::assertResponseIsSuccessful();
$dataB = $responseB->toArray();
self::assertSame('B', $dataB['zone']);
self::assertNotEmpty($dataB['entries']);
}
#[Test]
public function addPedagogicalDayPreservesImportedEntries(): void
{
// Import zone A (uses 'current' — required for year resolution)
$clientImport = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$responseImport = $clientImport->request('PUT', self::CONFIGURE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseIsSuccessful();
$importedCount = count($responseImport->toArray()['entries']);
// Add pedagogical day on top (same 'current' year — new client, kernel reboots)
$clientAdd = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$responseAdd = $clientAdd->request('POST', self::CONFIGURE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => 'Formation continue',
'description' => 'Formation pédagogies actives',
],
]);
self::assertResponseIsSuccessful();
$dataAfterAdd = $responseAdd->toArray();
// Imported entries preserved + new pedagogical day added
self::assertCount($importedCount + 1, $dataAfterAdd['entries']);
$types = array_column($dataAfterAdd['entries'], 'type');
self::assertContains('pedagogical', $types);
}
// =========================================================================
// AC5 - Journée pédagogique
// =========================================================================
#[Test]
public function addPedagogicalDayReturns200ForAdmin(): void
{
$this->persistCalendar();
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('POST', self::BASE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => 'Formation continue',
'description' => 'Formation sur les nouvelles pédagogies',
],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
$types = array_column($data['entries'], 'type');
self::assertContains('pedagogical', $types);
}
#[Test]
public function addPedagogicalDayReturns403ForProf(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PROF']);
$client->request('POST', self::BASE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => 'Formation continue',
],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function addPedagogicalDayReturns403ForEleve(): void
{
$client = $this->createAuthenticatedClient(['ROLE_ELEVE']);
$client->request('POST', self::BASE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => 'Formation continue',
],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// Validation - Bad Request
// =========================================================================
#[Test]
public function addPedagogicalDayReturns400ForWhitespaceOnlyLabel(): void
{
$this->persistCalendar();
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$client->request('POST', self::BASE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => ' ',
'description' => 'Label is only whitespace',
],
]);
self::assertResponseStatusCodeSame(400);
}
// =========================================================================
// Helpers
// =========================================================================
private function createAuthenticatedClient(array $roles): \ApiPlatform\Symfony\Bundle\Test\Client
{
$client = static::createClient();
$user = new SecurityUser(
userId: UserId::fromString(self::USER_ID),
email: 'test@classeo.local',
hashedPassword: '',
tenantId: TenantId::fromString(self::TENANT_ID),
roles: $roles,
);
$client->loginUser($user, 'api');
return $client;
}
private function persistCalendar(array $entries = [], ?SchoolZone $zone = null): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
if ($zone !== null) {
$calendar->configurerZone($zone);
}
foreach ($entries as $entry) {
$calendar->ajouterEntree($entry);
}
/** @var SchoolCalendarRepository $repository */
$repository = static::getContainer()->get(SchoolCalendarRepository::class);
$repository->save($calendar);
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Administration\Application;
use App\Administration\Application\Query\ValidateHomeworkDueDate\ValidateHomeworkDueDateHandler;
use App\Administration\Application\Query\ValidateHomeworkDueDate\ValidateHomeworkDueDateQuery;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Tests fonctionnels pour ValidateHomeworkDueDate.
*
* Vérifie le comportement bout-en-bout avec calendrier persisté en BDD.
* Complémente les tests unitaires qui utilisent un repository in-memory.
*
* @see Story 2.11 - AC3 (jours fériés), AC4 (périodes vacances)
*/
final class ValidateHomeworkDueDateFunctionalTest extends KernelTestCase
{
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string ACADEMIC_YEAR_ID = '11111111-1111-1111-1111-111111111111';
private ValidateHomeworkDueDateHandler $handler;
protected function setUp(): void
{
self::bootKernel();
$this->handler = static::getContainer()->get(ValidateHomeworkDueDateHandler::class);
}
protected function tearDown(): void
{
/** @var Connection $connection */
$connection = static::getContainer()->get(Connection::class);
$connection->executeStatement(
'DELETE FROM school_calendar_entries WHERE tenant_id = :tenant_id AND academic_year_id = :academic_year_id',
['tenant_id' => self::TENANT_ID, 'academic_year_id' => self::ACADEMIC_YEAR_ID],
);
parent::tearDown();
}
// =========================================================================
// AC3 (P0) — Blocage jours fériés
// =========================================================================
#[Test]
public function itRejectsHolidayAsHomeworkDueDate(): void
{
$this->persistCalendar([
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-12-25'),
endDate: new DateTimeImmutable('2024-12-25'),
label: 'Noël',
),
]);
// 2024-12-25 is a Wednesday (weekday) but a holiday
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2024-12-25',
));
self::assertFalse($result->valid);
self::assertNotNull($result->reason);
self::assertStringContainsString('férié', $result->reason);
}
// =========================================================================
// AC3/AC4 (P0/P1) — Blocage vacances
// =========================================================================
#[Test]
public function itRejectsVacationDayAsHomeworkDueDate(): void
{
$this->persistCalendar([
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-02-15'),
endDate: new DateTimeImmutable('2025-03-02'),
label: 'Vacances d\'hiver',
),
]);
// 2025-02-20 is a Thursday during the vacation
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-02-20',
));
self::assertFalse($result->valid);
self::assertNotNull($result->reason);
self::assertStringContainsString('vacances', $result->reason);
}
// =========================================================================
// AC4 (P1) — Warning retour vacances
// =========================================================================
#[Test]
public function itAcceptsReturnDayWithWarning(): void
{
$this->persistCalendar([
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-02-15'),
endDate: new DateTimeImmutable('2025-03-02'),
label: 'Vacances d\'hiver',
),
]);
// 2025-03-03 is a Monday, the day after vacation ends (2025-03-02)
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-03-03',
));
self::assertTrue($result->valid);
self::assertNotEmpty($result->warnings);
self::assertStringContainsString('retour de vacances', $result->warnings[0]);
}
// =========================================================================
// Cas nominal — jour ouvré normal
// =========================================================================
#[Test]
public function itAcceptsNormalWeekday(): void
{
$this->persistCalendar();
// 2025-03-10 is a Monday with no calendar entry
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-03-10',
));
self::assertTrue($result->valid);
self::assertNull($result->reason);
self::assertEmpty($result->warnings);
}
// =========================================================================
// Helpers
// =========================================================================
private function persistCalendar(array $entries = []): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
foreach ($entries as $entry) {
$calendar->ajouterEntree($entry);
}
/** @var SchoolCalendarRepository $repository */
$repository = static::getContainer()->get(SchoolCalendarRepository::class);
$repository->save($calendar);
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Administration\Infrastructure\Service;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Infrastructure\Service\FrenchPublicHolidaysCalculator;
use App\Administration\Infrastructure\Service\JsonOfficialCalendarProvider;
use function count;
use PHPUnit\Framework\Attributes\Large;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use function sprintf;
use Symfony\Component\HttpClient\HttpClient;
use function sys_get_temp_dir;
use function unlink;
/**
* Vérifie que l'API data.education.gouv.fr répond toujours correctement
* et que le format de réponse n'a pas changé.
*
* Ce test fait un appel réseau réel. Il échouera si :
* - l'URL de l'API change
* - le format de réponse (champs description, start_date, end_date, zones) change
* - l'API est indisponible
*/
#[Large]
final class GouvFrCalendarApiTest extends TestCase
{
private const string ACADEMIC_YEAR = '2024-2025';
private string $tempDir;
private JsonOfficialCalendarProvider $provider;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/classeo-calendar-test-' . uniqid();
mkdir($this->tempDir);
$this->provider = new JsonOfficialCalendarProvider(
dataDirectory: $this->tempDir,
httpClient: HttpClient::create(),
holidaysCalculator: new FrenchPublicHolidaysCalculator(),
logger: new NullLogger(),
);
}
protected function tearDown(): void
{
// Supprimer les fichiers générés
$files = glob($this->tempDir . '/*.json');
foreach ($files as $file) {
unlink($file);
}
rmdir($this->tempDir);
}
#[Test]
public function apiRetourneDesVacancesPourChaqueZone(): void
{
$vacationsA = $this->provider->vacancesParZone(SchoolZone::A, self::ACADEMIC_YEAR);
$vacationsB = $this->provider->vacancesParZone(SchoolZone::B, self::ACADEMIC_YEAR);
$vacationsC = $this->provider->vacancesParZone(SchoolZone::C, self::ACADEMIC_YEAR);
self::assertNotEmpty($vacationsA, 'L\'API doit retourner des vacances pour la zone A');
self::assertNotEmpty($vacationsB, 'L\'API doit retourner des vacances pour la zone B');
self::assertNotEmpty($vacationsC, 'L\'API doit retourner des vacances pour la zone C');
}
#[Test]
public function chaqueEntreeALesBonsChamps(): void
{
$entries = $this->provider->toutesEntreesOfficielles(SchoolZone::A, self::ACADEMIC_YEAR);
foreach ($entries as $entry) {
self::assertNotEmpty((string) $entry->id, 'Chaque entrée doit avoir un id');
self::assertInstanceOf(CalendarEntryType::class, $entry->type);
self::assertNotNull($entry->startDate, 'startDate ne doit pas être null');
self::assertNotNull($entry->endDate, 'endDate ne doit pas être null');
self::assertNotEmpty($entry->label, 'label ne doit pas être vide');
}
}
#[Test]
public function lesDatesDeVacancesSontCoherentes(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::A, self::ACADEMIC_YEAR);
foreach ($vacations as $vacation) {
self::assertSame(CalendarEntryType::VACATION, $vacation->type);
self::assertGreaterThanOrEqual(
$vacation->startDate->format('Y-m-d'),
$vacation->endDate->format('Y-m-d'),
sprintf('La fin (%s) doit être >= au début (%s) pour "%s"',
$vacation->endDate->format('Y-m-d'),
$vacation->startDate->format('Y-m-d'),
$vacation->label,
),
);
}
}
#[Test]
public function leFichierJsonEstCreeEnCache(): void
{
$expectedFile = $this->tempDir . '/official-holidays-' . self::ACADEMIC_YEAR . '.json';
self::assertFileDoesNotExist($expectedFile);
$this->provider->toutesEntreesOfficielles(SchoolZone::A, self::ACADEMIC_YEAR);
self::assertFileExists($expectedFile);
$content = json_decode(file_get_contents($expectedFile), true);
self::assertArrayHasKey('holidays', $content);
self::assertArrayHasKey('vacations', $content);
self::assertArrayHasKey('A', $content['vacations']);
self::assertArrayHasKey('B', $content['vacations']);
self::assertArrayHasKey('C', $content['vacations']);
}
#[Test]
public function leDeuxiemeAppelUtiliseLeCacheSansToucherLApi(): void
{
// Premier appel : fetch API + sauvegarde
$first = $this->provider->toutesEntreesOfficielles(SchoolZone::A, self::ACADEMIC_YEAR);
// Recréer un provider avec un HttpClient qui échoue systématiquement
// Si le cache fonctionne, il ne touchera pas au HttpClient
$cachedProvider = new JsonOfficialCalendarProvider(
dataDirectory: $this->tempDir,
httpClient: HttpClient::create(), // pas utilisé si le fichier existe
holidaysCalculator: new FrenchPublicHolidaysCalculator(),
logger: new NullLogger(),
);
$second = $cachedProvider->toutesEntreesOfficielles(SchoolZone::A, self::ACADEMIC_YEAR);
self::assertCount(count($first), $second);
}
#[Test]
public function vacancesToussaintEtNoelPresentes(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::A, self::ACADEMIC_YEAR);
$labels = array_map(static fn ($v) => $v->label, $vacations);
self::assertNotEmpty(
array_filter($labels, static fn (string $l) => str_contains(strtolower($l), 'toussaint')),
'Les vacances de la Toussaint doivent être présentes',
);
self::assertNotEmpty(
array_filter($labels, static fn (string $l) => str_contains(strtolower($l), 'noël') || str_contains(strtolower($l), 'noel')),
'Les vacances de Noël doivent être présentes',
);
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\AddPedagogicalDay;
use App\Administration\Application\Command\AddPedagogicalDay\AddPedagogicalDayCommand;
use App\Administration\Application\Command\AddPedagogicalDay\AddPedagogicalDayHandler;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AddPedagogicalDayHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemorySchoolCalendarRepository $repository;
private AddPedagogicalDayHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemorySchoolCalendarRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-17 10:00:00');
}
};
$this->handler = new AddPedagogicalDayHandler(
calendarRepository: $this->repository,
clock: $clock,
);
}
#[Test]
public function itAddsPedagogicalDayToExistingCalendar(): void
{
$this->seedCalendar();
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-14',
label: 'Formation enseignants',
);
$calendar = ($this->handler)($command);
$entries = $calendar->entries();
$pedagogicalDays = array_filter(
$entries,
static fn ($e) => $e->type === CalendarEntryType::PEDAGOGICAL_DAY,
);
self::assertCount(1, $pedagogicalDays);
$day = array_values($pedagogicalDays)[0];
self::assertSame('Formation enseignants', $day->label);
self::assertSame('2025-03-14', $day->startDate->format('Y-m-d'));
}
#[Test]
public function itCreatesNewCalendarIfNoneExists(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-14',
label: 'Formation enseignants',
);
$calendar = ($this->handler)($command);
self::assertCount(1, $calendar->entries());
}
#[Test]
public function itRecordsJourneePedagogiqueAjouteeEvent(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-14',
label: 'Formation enseignants',
);
$calendar = ($this->handler)($command);
$events = $calendar->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(JourneePedagogiqueAjoutee::class, $events[0]);
self::assertSame('Formation enseignants', $events[0]->label);
}
#[Test]
public function itSavesCalendarWithPedagogicalDay(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-14',
label: 'Formation',
description: 'Journée de formation continue',
);
($this->handler)($command);
$saved = $this->repository->getByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertCount(1, $saved->entries());
self::assertSame('Journée de formation continue', $saved->entries()[0]->description);
}
#[Test]
public function itRejectsMalformedDate(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: 'not-a-date',
label: 'Formation',
);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('La date doit être au format YYYY-MM-DD.');
($this->handler)($command);
}
#[Test]
public function itRejectsImpossibleCalendarDate(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-06-31',
label: 'Formation',
);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('La date n\'existe pas dans le calendrier.');
($this->handler)($command);
}
private function seedCalendar(): void
{
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$this->repository->save($calendar);
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ConfigureCalendar;
use App\Administration\Application\Command\ConfigureCalendar\ConfigureCalendarCommand;
use App\Administration\Application\Command\ConfigureCalendar\ConfigureCalendarHandler;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Administration\Infrastructure\Service\FrenchPublicHolidaysCalculator;
use App\Administration\Infrastructure\Service\JsonOfficialCalendarProvider;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use function sys_get_temp_dir;
final class ConfigureCalendarHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemorySchoolCalendarRepository $repository;
private ConfigureCalendarHandler $handler;
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/classeo-handler-test-' . uniqid();
$this->repository = new InMemorySchoolCalendarRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-17 10:00:00');
}
};
$this->handler = new ConfigureCalendarHandler(
calendarRepository: $this->repository,
calendarProvider: new JsonOfficialCalendarProvider(
dataDirectory: $this->tempDir,
httpClient: new MockHttpClient($this->mockApiResponse()),
holidaysCalculator: new FrenchPublicHolidaysCalculator(),
logger: new NullLogger(),
),
clock: $clock,
);
}
protected function tearDown(): void
{
$files = glob($this->tempDir . '/*');
if ($files !== false) {
foreach ($files as $file) {
unlink($file);
}
}
if (is_dir($this->tempDir)) {
rmdir($this->tempDir);
}
}
#[Test]
public function itConfiguresCalendarWithZoneA(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'A',
academicYear: '2024-2025',
);
$calendar = ($this->handler)($command);
self::assertSame(SchoolZone::A, $calendar->zone);
self::assertNotEmpty($calendar->entries());
}
#[Test]
public function itImportsHolidaysAndVacations(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'B',
academicYear: '2024-2025',
);
$calendar = ($this->handler)($command);
$holidays = array_filter(
$calendar->entries(),
static fn ($e) => $e->type === CalendarEntryType::HOLIDAY,
);
$vacations = array_filter(
$calendar->entries(),
static fn ($e) => $e->type === CalendarEntryType::VACATION,
);
self::assertNotEmpty($holidays);
self::assertNotEmpty($vacations);
}
#[Test]
public function itSavesCalendarToRepository(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'C',
academicYear: '2024-2025',
);
($this->handler)($command);
$saved = $this->repository->findByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNotNull($saved);
self::assertSame(SchoolZone::C, $saved->zone);
}
#[Test]
public function itReconfiguresExistingCalendar(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'A',
academicYear: '2024-2025',
);
($this->handler)($command);
$commandB = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'B',
academicYear: '2024-2025',
);
$calendar = ($this->handler)($commandB);
self::assertSame(SchoolZone::B, $calendar->zone);
}
#[Test]
public function itRecordsCalendrierConfigureEvent(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'A',
academicYear: '2024-2025',
);
$calendar = ($this->handler)($command);
$events = $calendar->pullDomainEvents();
self::assertNotEmpty($events);
}
private function mockApiResponse(): MockResponse
{
$records = [
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-21T23:00:00+00:00', 'end_date' => '2025-03-09T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-18T22:00:00+00:00', 'end_date' => '2025-05-04T22:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-07T23:00:00+00:00', 'end_date' => '2025-02-23T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-04T22:00:00+00:00', 'end_date' => '2025-04-20T22:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-14T23:00:00+00:00', 'end_date' => '2025-03-02T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-11T22:00:00+00:00', 'end_date' => '2025-04-27T22:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone C'],
];
return new MockResponse(json_encode(['results' => $records], JSON_THROW_ON_ERROR), ['http_code' => 200]);
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\IsSchoolDay;
use App\Administration\Application\Query\IsSchoolDay\IsSchoolDayHandler;
use App\Administration\Application\Query\IsSchoolDay\IsSchoolDayQuery;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class IsSchoolDayHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemorySchoolCalendarRepository $repository;
private IsSchoolDayHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemorySchoolCalendarRepository();
$this->handler = new IsSchoolDayHandler(
calendarRepository: $this->repository,
);
}
#[Test]
public function weekdayWithNoCalendarIsSchoolDay(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-10', // Lundi
);
self::assertTrue(($this->handler)($query));
}
#[Test]
public function saturdayIsNotSchoolDay(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-15', // Samedi
);
self::assertFalse(($this->handler)($query));
}
#[Test]
public function sundayIsNotSchoolDay(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-16', // Dimanche
);
self::assertFalse(($this->handler)($query));
}
#[Test]
public function holidayIsNotSchoolDay(): void
{
$this->seedCalendarWithHoliday('2025-05-01', 'Fête du travail');
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-05-01', // Jeudi férié
);
self::assertFalse(($this->handler)($query));
}
#[Test]
public function vacationDayIsNotSchoolDay(): void
{
$this->seedCalendarWithVacation('2025-02-08', '2025-02-23', 'Vacances hiver');
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-02-10', // Lundi en vacances
);
self::assertFalse(($this->handler)($query));
}
#[Test]
public function regularWeekdayWithCalendarIsSchoolDay(): void
{
$this->seedCalendarWithHoliday('2025-05-01', 'Fête du travail');
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-05-02', // Vendredi normal
);
self::assertTrue(($this->handler)($query));
}
#[Test]
public function itRejectsMalformedDate(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: 'invalid-date',
);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('La date doit être au format YYYY-MM-DD.');
($this->handler)($query);
}
#[Test]
public function itRejectsImpossibleCalendarDate(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-02-30',
);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('La date n\'existe pas dans le calendrier.');
($this->handler)($query);
}
private function seedCalendarWithHoliday(string $date, string $label): void
{
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable($date),
endDate: new DateTimeImmutable($date),
label: $label,
));
$this->repository->save($calendar);
}
private function seedCalendarWithVacation(string $start, string $end, string $label): void
{
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable($start),
endDate: new DateTimeImmutable($end),
label: $label,
));
$this->repository->save($calendar);
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\ValidateHomeworkDueDate;
use App\Administration\Application\Query\ValidateHomeworkDueDate\ValidateHomeworkDueDateHandler;
use App\Administration\Application\Query\ValidateHomeworkDueDate\ValidateHomeworkDueDateQuery;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ValidateHomeworkDueDateHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemorySchoolCalendarRepository $repository;
private ValidateHomeworkDueDateHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemorySchoolCalendarRepository();
$this->handler = new ValidateHomeworkDueDateHandler(
calendarRepository: $this->repository,
);
}
#[Test]
public function weekdayWithNoCalendarIsValid(): void
{
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-03-10', // Lundi
));
self::assertTrue($result->valid);
self::assertNull($result->reason);
self::assertSame([], $result->warnings);
}
#[Test]
public function weekendIsInvalid(): void
{
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-03-15', // Samedi
));
self::assertFalse($result->valid);
self::assertStringContainsString('weekend', $result->reason);
}
#[Test]
public function holidayIsInvalid(): void
{
$this->seedCalendarWithEntries(
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-05-01'),
endDate: new DateTimeImmutable('2025-05-01'),
label: 'Fête du travail',
),
);
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-05-01',
));
self::assertFalse($result->valid);
self::assertStringContainsString('férié', $result->reason);
}
#[Test]
public function vacationDayIsInvalid(): void
{
$this->seedCalendarWithEntries(
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-02-08'),
endDate: new DateTimeImmutable('2025-02-23'),
label: 'Vacances hiver',
),
);
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-02-10', // Lundi en vacances
));
self::assertFalse($result->valid);
self::assertStringContainsString('vacances', $result->reason);
}
#[Test]
public function returnDayFromVacationIsValidWithWarning(): void
{
$this->seedCalendarWithEntries(
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-02-08'),
endDate: new DateTimeImmutable('2025-02-23'),
label: 'Vacances hiver',
),
);
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-02-24', // Lundi retour de vacances
));
self::assertTrue($result->valid);
self::assertCount(1, $result->warnings);
self::assertStringContainsString('retour de vacances', $result->warnings[0]);
}
#[Test]
public function normalSchoolDayIsValid(): void
{
$this->seedCalendarWithEntries(
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-05-01'),
endDate: new DateTimeImmutable('2025-05-01'),
label: 'Fête du travail',
),
);
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-05-02', // Vendredi normal
));
self::assertTrue($result->valid);
self::assertSame([], $result->warnings);
}
#[Test]
public function malformedDateIsInvalid(): void
{
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: 'not-a-date',
));
self::assertFalse($result->valid);
self::assertStringContainsString('YYYY-MM-DD', $result->reason);
}
#[Test]
public function impossibleCalendarDateIsInvalid(): void
{
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-02-30',
));
self::assertFalse($result->valid);
self::assertStringContainsString('n\'existe pas', $result->reason);
}
private function seedCalendarWithEntries(CalendarEntry ...$entries): void
{
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
foreach ($entries as $entry) {
$calendar->ajouterEntree($entry);
}
$this->repository->save($calendar);
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Exception\CalendrierDatesInvalidesException;
use App\Administration\Domain\Exception\CalendrierLabelInvalideException;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CalendarEntryTest extends TestCase
{
#[Test]
public function creationValide(): void
{
$entry = $this->createHoliday();
self::assertSame(CalendarEntryType::HOLIDAY, $entry->type);
self::assertSame('Toussaint', $entry->label);
self::assertSame('2024-11-01', $entry->startDate->format('Y-m-d'));
self::assertSame('2024-11-01', $entry->endDate->format('Y-m-d'));
self::assertNull($entry->description);
}
#[Test]
public function creationAvecDescription(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::PEDAGOGICAL_DAY,
startDate: new DateTimeImmutable('2025-03-14'),
endDate: new DateTimeImmutable('2025-03-14'),
label: 'Formation enseignants',
description: 'Journée de formation continue',
);
self::assertSame('Journée de formation continue', $entry->description);
}
#[Test]
public function creationPeriodeVacances(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2024-10-19'),
endDate: new DateTimeImmutable('2024-11-03'),
label: 'Vacances de la Toussaint',
);
self::assertSame(CalendarEntryType::VACATION, $entry->type);
self::assertSame('2024-10-19', $entry->startDate->format('Y-m-d'));
self::assertSame('2024-11-03', $entry->endDate->format('Y-m-d'));
}
#[Test]
public function labelEstTrimme(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: ' Toussaint ',
);
self::assertSame('Toussaint', $entry->label);
}
#[Test]
public function dateFinAvantDebutLeveException(): void
{
$this->expectException(CalendrierDatesInvalidesException::class);
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-02'),
endDate: new DateTimeImmutable('2024-11-01'),
label: 'Invalid',
);
}
#[Test]
public function labelVideLeveException(): void
{
$this->expectException(CalendrierLabelInvalideException::class);
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: '',
);
}
#[Test]
public function labelTropCourtLeveException(): void
{
$this->expectException(CalendrierLabelInvalideException::class);
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: 'A',
);
}
#[Test]
public function labelTropLongLeveException(): void
{
$this->expectException(CalendrierLabelInvalideException::class);
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: str_repeat('A', 101),
);
}
#[Test]
public function couvreRetourneTruePourDateDansLaPeriode(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2024-10-19'),
endDate: new DateTimeImmutable('2024-11-03'),
label: 'Vacances Toussaint',
);
self::assertTrue($entry->couvre(new DateTimeImmutable('2024-10-19')));
self::assertTrue($entry->couvre(new DateTimeImmutable('2024-10-25')));
self::assertTrue($entry->couvre(new DateTimeImmutable('2024-11-03')));
}
#[Test]
public function couvreRetourneFalsePourDateHorsPeriode(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2024-10-19'),
endDate: new DateTimeImmutable('2024-11-03'),
label: 'Vacances Toussaint',
);
self::assertFalse($entry->couvre(new DateTimeImmutable('2024-10-18')));
self::assertFalse($entry->couvre(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function couvreJourUnique(): void
{
$entry = $this->createHoliday();
self::assertTrue($entry->couvre(new DateTimeImmutable('2024-11-01')));
self::assertFalse($entry->couvre(new DateTimeImmutable('2024-10-31')));
self::assertFalse($entry->couvre(new DateTimeImmutable('2024-11-02')));
}
private function createHoliday(): CalendarEntry
{
return new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: 'Toussaint',
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CalendarEntryTypeTest extends TestCase
{
#[Test]
public function chaqueTypeAUnLabel(): void
{
foreach (CalendarEntryType::cases() as $type) {
self::assertNotEmpty($type->label());
}
}
#[Test]
public function labelsAttendus(): void
{
self::assertSame('Jour férié', CalendarEntryType::HOLIDAY->label());
self::assertSame('Vacances scolaires', CalendarEntryType::VACATION->label());
self::assertSame('Journée pédagogique', CalendarEntryType::PEDAGOGICAL_DAY->label());
self::assertSame('Pont', CalendarEntryType::BRIDGE->label());
self::assertSame('Fermeture exceptionnelle', CalendarEntryType::EXCEPTIONAL_CLOSURE->label());
}
#[Test]
public function backedValuesConsistantes(): void
{
self::assertSame('holiday', CalendarEntryType::HOLIDAY->value);
self::assertSame('vacation', CalendarEntryType::VACATION->value);
self::assertSame('pedagogical', CalendarEntryType::PEDAGOGICAL_DAY->value);
self::assertSame('bridge', CalendarEntryType::BRIDGE->value);
self::assertSame('closure', CalendarEntryType::EXCEPTIONAL_CLOSURE->value);
}
}

View File

@@ -0,0 +1,420 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Event\CalendrierConfigure;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
use App\Administration\Domain\Exception\CalendrierEntreeNonTrouveeException;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use function sprintf;
final class SchoolCalendarTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function initialiserCreeCalendrierVide(): void
{
$calendar = $this->createCalendar();
self::assertTrue($calendar->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
self::assertTrue($calendar->academicYearId->equals(AcademicYearId::fromString(self::ACADEMIC_YEAR_ID)));
self::assertNull($calendar->zone);
self::assertEmpty($calendar->entries());
}
#[Test]
public function configurerZoneDefInitLaZone(): void
{
$calendar = $this->createCalendar();
$calendar->configurerZone(SchoolZone::A);
self::assertSame(SchoolZone::A, $calendar->zone);
}
#[Test]
public function configurerZonePeutEtreChangee(): void
{
$calendar = $this->createCalendar();
$calendar->configurerZone(SchoolZone::A);
$calendar->configurerZone(SchoolZone::C);
self::assertSame(SchoolZone::C, $calendar->zone);
}
#[Test]
public function ajouterEntreeAjouteAuCalendrier(): void
{
$calendar = $this->createCalendar();
$entry = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$calendar->ajouterEntree($entry);
self::assertCount(1, $calendar->entries());
self::assertSame($entry, $calendar->entries()[0]);
}
#[Test]
public function ajouterPlusieursEntrees(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'));
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-11', '2024-11-11', 'Armistice'));
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::VACATION, '2024-12-21', '2025-01-05', 'Vacances de Noël'));
self::assertCount(3, $calendar->entries());
}
#[Test]
public function supprimerEntreeRetireEntreeExistante(): void
{
$calendar = $this->createCalendar();
$entry = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$calendar->ajouterEntree($entry);
$calendar->supprimerEntree($entry->id);
self::assertEmpty($calendar->entries());
}
#[Test]
public function supprimerEntreeInexistanteLeveException(): void
{
$calendar = $this->createCalendar();
$this->expectException(CalendrierEntreeNonTrouveeException::class);
$calendar->supprimerEntree(CalendarEntryId::generate());
}
#[Test]
public function viderEntreesSupprimeTout(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'));
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-11', '2024-11-11', 'Armistice'));
$calendar->viderEntrees();
self::assertEmpty($calendar->entries());
}
#[Test]
public function estJourOuvreRetourneTruePourJourSemaine(): void
{
$calendar = $this->createCalendar();
// Lundi 4 novembre 2024
self::assertTrue($calendar->estJourOuvre(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function estJourOuvreRetourneFalsePourSamedi(): void
{
$calendar = $this->createCalendar();
// Samedi 2 novembre 2024
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2024-11-02')));
}
#[Test]
public function estJourOuvreRetourneFalsePourDimanche(): void
{
$calendar = $this->createCalendar();
// Dimanche 3 novembre 2024
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2024-11-03')));
}
#[Test]
public function estJourOuvreRetourneFalsePourJourFerie(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'),
);
// Vendredi 1er novembre 2024 (Toussaint)
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2024-11-01')));
}
#[Test]
public function estJourOuvreRetourneFalsePendantVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
// Mercredi 23 octobre 2024 (en plein dans les vacances)
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2024-10-23')));
}
#[Test]
public function estJourOuvreRetourneFalsePourJourneePedagogique(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::PEDAGOGICAL_DAY, '2025-03-14', '2025-03-14', 'Formation'),
);
// Vendredi 14 mars 2025
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2025-03-14')));
}
#[Test]
public function estJourOuvreRetourneTrueApresVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
// Lundi 4 novembre 2024 (jour de reprise)
self::assertTrue($calendar->estJourOuvre(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function trouverEntreePourDateRetourneEntreeCorrespondante(): void
{
$calendar = $this->createCalendar();
$holiday = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$calendar->ajouterEntree($holiday);
$found = $calendar->trouverEntreePourDate(new DateTimeImmutable('2024-11-01'));
self::assertNotNull($found);
self::assertSame('Toussaint', $found->label);
}
#[Test]
public function trouverEntreePourDateRetourneNullSiAucune(): void
{
$calendar = $this->createCalendar();
$found = $calendar->trouverEntreePourDate(new DateTimeImmutable('2024-11-01'));
self::assertNull($found);
}
#[Test]
public function estEnVacancesRetourneTruePendantVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
self::assertTrue($calendar->estEnVacances(new DateTimeImmutable('2024-10-25')));
}
#[Test]
public function estEnVacancesRetourneFalseHorsVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
self::assertFalse($calendar->estEnVacances(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function estEnVacancesRetourneFalsePourJourFerie(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'),
);
self::assertFalse($calendar->estEnVacances(new DateTimeImmutable('2024-11-01')));
}
#[Test]
public function estJourRetourVacancesRetourneTruePourJourApresFinVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
// 4 novembre = lendemain de la fin des vacances (3 novembre)
self::assertTrue($calendar->estJourRetourVacances(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function estJourRetourVacancesRetourneFalsePourJourNormal(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
self::assertFalse($calendar->estJourRetourVacances(new DateTimeImmutable('2024-11-05')));
}
#[Test]
public function configurerDefinitZoneEtImporteEntreesAvecEvenement(): void
{
$calendar = $this->createCalendar();
$entries = [
$this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'),
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
];
$at = new DateTimeImmutable('2026-02-17 10:00:00');
$calendar->configurer(SchoolZone::A, $entries, $at);
self::assertSame(SchoolZone::A, $calendar->zone);
self::assertCount(2, $calendar->entries());
$events = $calendar->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CalendrierConfigure::class, $events[0]);
self::assertSame(SchoolZone::A, $events[0]->zone);
self::assertSame(2, $events[0]->nombreEntrees);
$expectedAggregateId = Uuid::uuid5(
Uuid::NAMESPACE_DNS,
sprintf('school-calendar:%s:%s', self::TENANT_ID, self::ACADEMIC_YEAR_ID),
);
self::assertTrue($events[0]->aggregateId()->equals($expectedAggregateId));
}
#[Test]
public function configurerPreserveJourneesPedagogiques(): void
{
$calendar = $this->createCalendar();
$pedaEntry = $this->createEntry(CalendarEntryType::PEDAGOGICAL_DAY, '2025-03-14', '2025-03-14', 'Formation');
$calendar->ajouterEntree($pedaEntry);
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'));
$entries = [
$this->createEntry(CalendarEntryType::VACATION, '2024-12-21', '2025-01-05', 'Vacances de Noël'),
];
$calendar->configurer(SchoolZone::A, $entries, new DateTimeImmutable());
// 1 preserved pedagogical day + 1 new vacation = 2
self::assertCount(2, $calendar->entries());
$types = array_map(static fn (CalendarEntry $e) => $e->type, $calendar->entries());
self::assertContains(CalendarEntryType::PEDAGOGICAL_DAY, $types);
self::assertContains(CalendarEntryType::VACATION, $types);
self::assertNotContains(CalendarEntryType::HOLIDAY, $types);
}
#[Test]
public function configurerReemplaceEntreesExistantes(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Ancienne'));
$entries = [
$this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-11', '2024-11-11', 'Armistice'),
];
$calendar->configurer(SchoolZone::B, $entries, new DateTimeImmutable());
self::assertCount(1, $calendar->entries());
self::assertSame('Armistice', $calendar->entries()[0]->label);
}
#[Test]
public function ajouterJourneePedagogiqueEmetEvenement(): void
{
$calendar = $this->createCalendar();
$entry = $this->createEntry(CalendarEntryType::PEDAGOGICAL_DAY, '2025-03-14', '2025-03-14', 'Formation enseignants');
$at = new DateTimeImmutable('2026-02-17 10:00:00');
$calendar->ajouterJourneePedagogique($entry, $at);
self::assertCount(1, $calendar->entries());
$events = $calendar->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(JourneePedagogiqueAjoutee::class, $events[0]);
self::assertSame('Formation enseignants', $events[0]->label);
self::assertSame('2025-03-14', $events[0]->date->format('Y-m-d'));
$expectedAggregateId = Uuid::uuid5(
Uuid::NAMESPACE_DNS,
sprintf('school-calendar:%s:%s', self::TENANT_ID, self::ACADEMIC_YEAR_ID),
);
self::assertTrue($events[0]->aggregateId()->equals($expectedAggregateId));
}
#[Test]
public function ajouterJourneePedagogiqueRefuseTypeDifferent(): void
{
$calendar = $this->createCalendar();
$entry = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$this->expectException(InvalidArgumentException::class);
$calendar->ajouterJourneePedagogique($entry, new DateTimeImmutable());
}
#[Test]
public function reconstituteRestaureLEtat(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$yearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$entry1 = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$entry2 = $this->createEntry(CalendarEntryType::VACATION, '2024-12-21', '2025-01-05', 'Noël');
$calendar = SchoolCalendar::reconstitute(
tenantId: $tenantId,
academicYearId: $yearId,
zone: SchoolZone::B,
entries: [$entry1, $entry2],
);
self::assertTrue($calendar->tenantId->equals($tenantId));
self::assertTrue($calendar->academicYearId->equals($yearId));
self::assertSame(SchoolZone::B, $calendar->zone);
self::assertCount(2, $calendar->entries());
self::assertEmpty($calendar->pullDomainEvents());
}
private function createCalendar(): SchoolCalendar
{
return SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
}
private function createEntry(
CalendarEntryType $type,
string $startDate,
string $endDate,
string $label,
): CalendarEntry {
return new CalendarEntry(
id: CalendarEntryId::generate(),
type: $type,
startDate: new DateTimeImmutable($startDate),
endDate: new DateTimeImmutable($endDate),
label: $label,
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SchoolZoneTest extends TestCase
{
#[Test]
public function zoneAContientLyonEtBordeaux(): void
{
$academies = SchoolZone::A->academies();
self::assertContains('Lyon', $academies);
self::assertContains('Bordeaux', $academies);
self::assertContains('Grenoble', $academies);
}
#[Test]
public function zoneBContientLilleEtNantes(): void
{
$academies = SchoolZone::B->academies();
self::assertContains('Lille', $academies);
self::assertContains('Nantes', $academies);
self::assertContains('Strasbourg', $academies);
}
#[Test]
public function zoneCContientParisEtToulouse(): void
{
$academies = SchoolZone::C->academies();
self::assertContains('Paris', $academies);
self::assertContains('Toulouse', $academies);
self::assertContains('Versailles', $academies);
}
#[Test]
public function backedValues(): void
{
self::assertSame('A', SchoolZone::A->value);
self::assertSame('B', SchoolZone::B->value);
self::assertSame('C', SchoolZone::C->value);
}
#[Test]
public function chaqueZoneADesAcademies(): void
{
foreach (SchoolZone::cases() as $zone) {
self::assertNotEmpty($zone->academies());
}
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Messaging\NotifyTeachersPedagogicalDayHandler;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use RuntimeException;
use Symfony\Component\Mailer\MailerInterface;
use Twig\Environment;
final class NotifyTeachersPedagogicalDayHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function itSendsEmailToAllTeachersInTenant(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$userRepository = $this->createMock(UserRepository::class);
$twig->method('render')->willReturn('<html>notification</html>');
$userRepository->method('findAllByTenant')->willReturn([
$this->createUser('teacher1@school.fr', [Role::PROF]),
$this->createUser('teacher2@school.fr', [Role::PROF]),
$this->createUser('parent@school.fr', [Role::PARENT]),
]);
$mailer->expects(self::exactly(2))->method('send');
$handler = new NotifyTeachersPedagogicalDayHandler($mailer, $twig, $userRepository, new NullLogger());
($handler)($this->createEvent());
}
#[Test]
public function itSkipsWhenNoTeachersInTenant(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$userRepository = $this->createMock(UserRepository::class);
$userRepository->method('findAllByTenant')->willReturn([
$this->createUser('parent@school.fr', [Role::PARENT]),
]);
$mailer->expects(self::never())->method('send');
$twig->expects(self::never())->method('render');
$handler = new NotifyTeachersPedagogicalDayHandler($mailer, $twig, $userRepository, new NullLogger());
($handler)($this->createEvent());
}
#[Test]
public function itHandlesMailerFailureGracefully(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$userRepository = $this->createMock(UserRepository::class);
$twig->method('render')->willReturn('<html>notification</html>');
$userRepository->method('findAllByTenant')->willReturn([
$this->createUser('teacher@school.fr', [Role::PROF]),
]);
$mailer->method('send')->willThrowException(new RuntimeException('SMTP error'));
$handler = new NotifyTeachersPedagogicalDayHandler($mailer, $twig, $userRepository, new NullLogger());
($handler)($this->createEvent());
$this->addToAssertionCount(1);
}
private function createEvent(): JourneePedagogiqueAjoutee
{
return new JourneePedagogiqueAjoutee(
entryId: CalendarEntryId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
date: new DateTimeImmutable('2025-03-14'),
label: 'Formation enseignants',
occurredOn: new DateTimeImmutable('2026-02-18 10:00:00'),
);
}
/**
* @param Role[] $roles
*/
private function createUser(string $email, array $roles): User
{
return User::reconstitute(
id: UserId::generate(),
email: new Email($email),
roles: $roles,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: StatutCompte::ACTIF,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-01'),
hashedPassword: 'hashed',
activatedAt: new DateTimeImmutable('2026-01-02'),
consentementParental: null,
);
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\CalendrierNonTrouveException;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DoctrineSchoolCalendarRepositoryTest extends TestCase
{
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function saveDeletesExistingEntriesThenInsertsNew(): void
{
$connection = $this->createMock(Connection::class);
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::fromString('550e8400-e29b-41d4-a716-446655440020'),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-11-01'),
endDate: new DateTimeImmutable('2025-11-01'),
label: 'Toussaint',
));
$connection->expects(self::once())
->method('transactional')
->willReturnCallback(static function (callable $callback) { $callback(); });
// Expect: 1 DELETE + 1 INSERT
$connection->expects(self::exactly(2))
->method('executeStatement')
->with(
self::logicalOr(
self::stringContains('DELETE FROM school_calendar_entries'),
self::stringContains('INSERT INTO school_calendar_entries'),
),
self::isType('array'),
);
$repository = new DoctrineSchoolCalendarRepository($connection);
$repository->save($calendar);
}
#[Test]
public function saveWithMultipleEntriesExecutesMultipleInserts(): void
{
$connection = $this->createMock(Connection::class);
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::fromString('550e8400-e29b-41d4-a716-446655440020'),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-11-01'),
endDate: new DateTimeImmutable('2025-11-01'),
label: 'Toussaint',
));
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::fromString('550e8400-e29b-41d4-a716-446655440021'),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-12-21'),
endDate: new DateTimeImmutable('2026-01-05'),
label: 'Noël',
));
$connection->expects(self::once())
->method('transactional')
->willReturnCallback(static function (callable $callback) { $callback(); });
// Expect: 1 DELETE + 2 INSERTs = 3 calls
$connection->expects(self::exactly(3))
->method('executeStatement');
$repository = new DoctrineSchoolCalendarRepository($connection);
$repository->save($calendar);
}
#[Test]
public function findByTenantAndYearReturnsNullWhenNoEntries(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')->willReturn([]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$calendar = $repository->findByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNull($calendar);
}
#[Test]
public function findByTenantAndYearReturnsCalendarWithEntries(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')
->willReturn([
$this->makeRow('550e8400-e29b-41d4-a716-446655440020', 'holiday', '2025-11-01', '2025-11-01', 'Toussaint', null, 'A'),
$this->makeRow('550e8400-e29b-41d4-a716-446655440021', 'vacation', '2025-12-21', '2026-01-05', 'Noël', 'Vacances de Noël', 'A'),
]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$calendar = $repository->findByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNotNull($calendar);
self::assertCount(2, $calendar->entries());
self::assertSame(SchoolZone::A, $calendar->zone);
self::assertSame('Toussaint', $calendar->entries()[0]->label);
self::assertSame('Noël', $calendar->entries()[1]->label);
}
#[Test]
public function findByTenantAndYearHandlesNullZone(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')
->willReturn([
$this->makeRow('550e8400-e29b-41d4-a716-446655440020', 'pedagogical', '2025-03-14', '2025-03-14', 'Formation', null, null),
]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$calendar = $repository->findByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNotNull($calendar);
self::assertNull($calendar->zone);
}
#[Test]
public function getByTenantAndYearThrowsWhenNotFound(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')->willReturn([]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$this->expectException(CalendrierNonTrouveException::class);
$repository->getByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
}
#[Test]
public function getByTenantAndYearReturnsCalendarWhenFound(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')
->willReturn([
$this->makeRow('550e8400-e29b-41d4-a716-446655440020', 'holiday', '2025-05-01', '2025-05-01', 'Fête du travail', null, 'B'),
]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$calendar = $repository->getByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertCount(1, $calendar->entries());
self::assertSame(SchoolZone::B, $calendar->zone);
}
/**
* @return array<string, mixed>
*/
private function makeRow(
string $id,
string $entryType,
string $startDate,
string $endDate,
string $label,
?string $description,
?string $zone,
): array {
return [
'id' => $id,
'tenant_id' => self::TENANT_ID,
'academic_year_id' => self::ACADEMIC_YEAR_ID,
'entry_type' => $entryType,
'start_date' => $startDate,
'end_date' => $endDate,
'label' => $label,
'description' => $description,
'zone' => $zone,
'created_at' => '2026-02-17T10:00:00+00:00',
];
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Security\CalendarVoter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
final class CalendarVoterTest extends TestCase
{
private CalendarVoter $voter;
protected function setUp(): void
{
$this->voter = new CalendarVoter();
}
#[Test]
public function itAbstainsForUnrelatedAttributes(): void
{
$token = $this->tokenWithRole(Role::ADMIN->value);
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
}
#[Test]
public function itDeniesAccessToUnauthenticatedUsers(): void
{
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn(null);
$result = $this->voter->vote($token, null, [CalendarVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
// --- VIEW ---
#[Test]
#[DataProvider('viewAllowedRolesProvider')]
public function itGrantsViewToStaffRoles(string $role): void
{
$token = $this->tokenWithRole($role);
$result = $this->voter->vote($token, null, [CalendarVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
/**
* @return iterable<string, array{string}>
*/
public static function viewAllowedRolesProvider(): iterable
{
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
yield 'ADMIN' => [Role::ADMIN->value];
yield 'PROF' => [Role::PROF->value];
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
}
#[Test]
#[DataProvider('viewDeniedRolesProvider')]
public function itDeniesViewToNonStaffRoles(string $role): void
{
$token = $this->tokenWithRole($role);
$result = $this->voter->vote($token, null, [CalendarVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
/**
* @return iterable<string, array{string}>
*/
public static function viewDeniedRolesProvider(): iterable
{
yield 'PARENT' => [Role::PARENT->value];
yield 'ELEVE' => [Role::ELEVE->value];
}
// --- CONFIGURE ---
#[Test]
#[DataProvider('configureAllowedRolesProvider')]
public function itGrantsConfigureToAdminRoles(string $role): void
{
$token = $this->tokenWithRole($role);
$result = $this->voter->vote($token, null, [CalendarVoter::CONFIGURE]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
/**
* @return iterable<string, array{string}>
*/
public static function configureAllowedRolesProvider(): iterable
{
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
yield 'ADMIN' => [Role::ADMIN->value];
}
#[Test]
#[DataProvider('configureDeniedRolesProvider')]
public function itDeniesConfigureToNonAdminRoles(string $role): void
{
$token = $this->tokenWithRole($role);
$result = $this->voter->vote($token, null, [CalendarVoter::CONFIGURE]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
/**
* @return iterable<string, array{string}>
*/
public static function configureDeniedRolesProvider(): iterable
{
yield 'PROF' => [Role::PROF->value];
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
yield 'PARENT' => [Role::PARENT->value];
yield 'ELEVE' => [Role::ELEVE->value];
}
private function tokenWithRole(string $role): TokenInterface
{
$user = $this->createMock(UserInterface::class);
$user->method('getRoles')->willReturn([$role]);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($user);
return $token;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Service;
use App\Administration\Infrastructure\Service\FrenchPublicHolidaysCalculator;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class FrenchPublicHolidaysCalculatorTest extends TestCase
{
private FrenchPublicHolidaysCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new FrenchPublicHolidaysCalculator();
}
#[Test]
public function itReturns11HolidaysForAcademicYear(): void
{
$holidays = $this->calculator->pourAnneeScolaire('2024-2025');
self::assertCount(11, $holidays);
}
#[Test]
public function itReturnsFixedHolidaysFor20242025(): void
{
$holidays = $this->calculator->pourAnneeScolaire('2024-2025');
$dates = array_column($holidays, 'date');
self::assertContains('2024-11-01', $dates); // Toussaint
self::assertContains('2024-11-11', $dates); // Armistice
self::assertContains('2024-12-25', $dates); // Noël
self::assertContains('2025-01-01', $dates); // Jour de l'an
self::assertContains('2025-05-01', $dates); // Fête du travail
self::assertContains('2025-05-08', $dates); // Victoire 1945
self::assertContains('2025-07-14', $dates); // Fête nationale
self::assertContains('2025-08-15', $dates); // Assomption
}
#[Test]
public function itCalculatesEasterBasedHolidaysFor2025(): void
{
// Pâques 2025 = 20 avril 2025
$holidays = $this->calculator->pourAnneeScolaire('2024-2025');
$dates = array_column($holidays, 'date');
self::assertContains('2025-04-21', $dates); // Lundi de Pâques (20 avril + 1)
self::assertContains('2025-05-29', $dates); // Ascension (20 avril + 39)
self::assertContains('2025-06-09', $dates); // Pentecôte (20 avril + 50)
}
#[Test]
public function itCalculatesEasterBasedHolidaysFor2026(): void
{
// Pâques 2026 = 5 avril 2026
$holidays = $this->calculator->pourAnneeScolaire('2025-2026');
$dates = array_column($holidays, 'date');
self::assertContains('2026-04-06', $dates); // Lundi de Pâques (5 avril + 1)
self::assertContains('2026-05-14', $dates); // Ascension (5 avril + 39)
self::assertContains('2026-05-25', $dates); // Pentecôte (5 avril + 50)
}
#[Test]
public function holidaysAreSortedByDate(): void
{
$holidays = $this->calculator->pourAnneeScolaire('2024-2025');
$dates = array_column($holidays, 'date');
$sorted = $dates;
sort($sorted);
self::assertSame($sorted, $dates);
}
#[Test]
public function eachHolidayHasDateAndLabel(): void
{
$holidays = $this->calculator->pourAnneeScolaire('2025-2026');
foreach ($holidays as $holiday) {
self::assertArrayHasKey('date', $holiday);
self::assertArrayHasKey('label', $holiday);
self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $holiday['date']);
self::assertNotEmpty($holiday['label']);
}
}
}

View File

@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Service;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Infrastructure\Service\FrenchPublicHolidaysCalculator;
use App\Administration\Infrastructure\Service\JsonOfficialCalendarProvider;
use function count;
use InvalidArgumentException;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use RuntimeException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use function sys_get_temp_dir;
final class JsonOfficialCalendarProviderTest extends TestCase
{
private string $tempDir;
private JsonOfficialCalendarProvider $provider;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/classeo-calendar-test-' . uniqid();
$this->provider = new JsonOfficialCalendarProvider(
dataDirectory: $this->tempDir,
httpClient: new MockHttpClient($this->mockApiResponse()),
holidaysCalculator: new FrenchPublicHolidaysCalculator(),
logger: new NullLogger(),
);
}
protected function tearDown(): void
{
// Clean up temp files
$files = glob($this->tempDir . '/*');
if ($files !== false) {
foreach ($files as $file) {
unlink($file);
}
}
if (is_dir($this->tempDir)) {
rmdir($this->tempDir);
}
}
#[Test]
public function joursFeiesRetourneLesFeriesOfficiels(): void
{
$holidays = $this->provider->joursFeries('2024-2025');
self::assertNotEmpty($holidays);
foreach ($holidays as $holiday) {
self::assertSame(CalendarEntryType::HOLIDAY, $holiday->type);
}
}
#[Test]
public function joursFeiesContientToussaint(): void
{
$holidays = $this->provider->joursFeries('2024-2025');
$labels = array_map(static fn ($h) => $h->label, $holidays);
self::assertContains('Toussaint', $labels);
}
#[Test]
public function joursFeiesContientFeteNationale(): void
{
$holidays = $this->provider->joursFeries('2024-2025');
$labels = array_map(static fn ($h) => $h->label, $holidays);
self::assertContains('Fête nationale', $labels);
}
#[Test]
public function joursFeiesContientAssomption(): void
{
$holidays = $this->provider->joursFeries('2024-2025');
$labels = array_map(static fn ($h) => $h->label, $holidays);
self::assertContains('Assomption', $labels);
}
#[Test]
public function vacancesParZoneRetourneLesVacancesDeZoneA(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
self::assertNotEmpty($vacations);
foreach ($vacations as $vacation) {
self::assertSame(CalendarEntryType::VACATION, $vacation->type);
}
}
#[Test]
public function vacancesParZoneRetourneLesVacancesDeZoneB(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::B, '2024-2025');
self::assertNotEmpty($vacations);
}
#[Test]
public function vacancesParZoneRetourneLesVacancesDeZoneC(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::C, '2024-2025');
self::assertNotEmpty($vacations);
}
#[Test]
public function vacancesContiennentToussaintEtNoel(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
$labels = array_map(static fn ($v) => $v->label, $vacations);
self::assertContains('Vacances de la Toussaint', $labels);
self::assertContains('Vacances de Noël', $labels);
}
#[Test]
public function hiverDiffereSelonLaZone(): void
{
$vacationsA = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
$vacationsC = $this->provider->vacancesParZone(SchoolZone::C, '2024-2025');
$hiverA = null;
$hiverC = null;
foreach ($vacationsA as $v) {
if (str_contains($v->label, 'hiver')) {
$hiverA = $v;
}
}
foreach ($vacationsC as $v) {
if (str_contains($v->label, 'hiver')) {
$hiverC = $v;
}
}
self::assertNotNull($hiverA);
self::assertNotNull($hiverC);
self::assertNotSame(
$hiverA->startDate->format('Y-m-d'),
$hiverC->startDate->format('Y-m-d'),
);
}
#[Test]
public function toutesEntreesOfficiellesCombineJoursFeiesEtVacances(): void
{
$all = $this->provider->toutesEntreesOfficielles(SchoolZone::A, '2024-2025');
$holidays = $this->provider->joursFeries('2024-2025');
$vacations = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
self::assertCount(count($holidays) + count($vacations), $all);
}
#[Test]
public function anneeScolaireInconnueLeveException(): void
{
$provider = new JsonOfficialCalendarProvider(
dataDirectory: $this->tempDir,
httpClient: new MockHttpClient(new MockResponse('', ['http_code' => 500])),
holidaysCalculator: new FrenchPublicHolidaysCalculator(),
logger: new NullLogger(),
);
$this->expectException(RuntimeException::class);
$provider->joursFeries('2099-2100');
}
#[Test]
public function chaqueEntreeAUnIdUnique(): void
{
$all = $this->provider->toutesEntreesOfficielles(SchoolZone::A, '2024-2025');
$ids = array_map(static fn ($entry) => (string) $entry->id, $all);
$uniqueIds = array_unique($ids);
self::assertCount(count($all), $uniqueIds);
}
#[Test]
public function itRejectsPathTraversalInAcademicYear(): void
{
$this->expectException(InvalidArgumentException::class);
$this->provider->joursFeries('../../etc/passwd');
}
#[Test]
public function itRejectsODataInjectionInAcademicYear(): void
{
$this->expectException(InvalidArgumentException::class);
$this->provider->joursFeries('2024-2025"; DROP TABLE');
}
#[Test]
public function cacheFileIsCreatedOnFirstAccess(): void
{
$this->provider->joursFeries('2024-2025');
self::assertFileExists($this->tempDir . '/official-holidays-2024-2025.json');
}
#[Test]
public function apiStartDateIsAdjustedByOneDay(): void
{
// The gouv.fr API returns start_date as "la veille à 23h UTC" (e.g., 2024-10-18T23:00:00+00:00
// for a vacation starting 2024-10-19). The provider adds +1 day to start_date only.
// end_date represents the last vacation day directly (no shift).
$vacations = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
$toussaint = null;
foreach ($vacations as $v) {
if (str_contains($v->label, 'Toussaint')) {
$toussaint = $v;
break;
}
}
self::assertNotNull($toussaint);
// Mock API has start_date '2024-10-18T23:00:00+00:00' → adjusted to 2024-10-19
self::assertSame('2024-10-19', $toussaint->startDate->format('Y-m-d'));
// Mock API has end_date '2024-11-03T23:00:00+00:00' → kept as 2024-11-03 (last vacation day)
self::assertSame('2024-11-03', $toussaint->endDate->format('Y-m-d'));
}
/**
* Creates a MockResponse simulating the gouv.fr API for 2024-2025 vacations.
*/
private function mockApiResponse(): MockResponse
{
$records = [
// Zone A
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-21T23:00:00+00:00', 'end_date' => '2025-03-09T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-18T22:00:00+00:00', 'end_date' => '2025-05-04T22:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone A'],
// Zone B
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-07T23:00:00+00:00', 'end_date' => '2025-02-23T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-04T22:00:00+00:00', 'end_date' => '2025-04-20T22:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone B'],
// Zone C
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-14T23:00:00+00:00', 'end_date' => '2025-03-02T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-11T22:00:00+00:00', 'end_date' => '2025-04-27T22:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone C'],
];
$body = json_encode(['results' => $records], JSON_THROW_ON_ERROR);
return new MockResponse($body, ['http_code' => 200]);
}
}