diff --git a/backend/config/services.yaml b/backend/config/services.yaml index e978537..3bfd0c7 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -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: diff --git a/backend/migrations/Version20260217093243.php b/backend/migrations/Version20260217093243.php new file mode 100644 index 0000000..888f143 --- /dev/null +++ b/backend/migrations/Version20260217093243.php @@ -0,0 +1,48 @@ +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'); + } +} diff --git a/backend/migrations/Version20260217231503.php b/backend/migrations/Version20260217231503.php new file mode 100644 index 0000000..cd0ff9e --- /dev/null +++ b/backend/migrations/Version20260217231503.php @@ -0,0 +1,30 @@ +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)'); + } +} diff --git a/backend/src/Administration/Application/Command/AddPedagogicalDay/AddPedagogicalDayCommand.php b/backend/src/Administration/Application/Command/AddPedagogicalDay/AddPedagogicalDayCommand.php new file mode 100644 index 0000000..2899b60 --- /dev/null +++ b/backend/src/Administration/Application/Command/AddPedagogicalDay/AddPedagogicalDayCommand.php @@ -0,0 +1,20 @@ +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; + } +} diff --git a/backend/src/Administration/Application/Command/ConfigureCalendar/ConfigureCalendarCommand.php b/backend/src/Administration/Application/Command/ConfigureCalendar/ConfigureCalendarCommand.php new file mode 100644 index 0000000..5d455bb --- /dev/null +++ b/backend/src/Administration/Application/Command/ConfigureCalendar/ConfigureCalendarCommand.php @@ -0,0 +1,19 @@ +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; + } +} diff --git a/backend/src/Administration/Application/Port/OfficialCalendarProvider.php b/backend/src/Administration/Application/Port/OfficialCalendarProvider.php new file mode 100644 index 0000000..e5068f7 --- /dev/null +++ b/backend/src/Administration/Application/Port/OfficialCalendarProvider.php @@ -0,0 +1,35 @@ +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); + } +} diff --git a/backend/src/Administration/Application/Query/IsSchoolDay/IsSchoolDayQuery.php b/backend/src/Administration/Application/Query/IsSchoolDay/IsSchoolDayQuery.php new file mode 100644 index 0000000..5b6d14a --- /dev/null +++ b/backend/src/Administration/Application/Query/IsSchoolDay/IsSchoolDayQuery.php @@ -0,0 +1,15 @@ +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(); + } +} diff --git a/backend/src/Administration/Application/Query/ValidateHomeworkDueDate/ValidateHomeworkDueDateQuery.php b/backend/src/Administration/Application/Query/ValidateHomeworkDueDate/ValidateHomeworkDueDateQuery.php new file mode 100644 index 0000000..2201a85 --- /dev/null +++ b/backend/src/Administration/Application/Query/ValidateHomeworkDueDate/ValidateHomeworkDueDateQuery.php @@ -0,0 +1,15 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return Uuid::uuid5( + Uuid::NAMESPACE_DNS, + sprintf('school-calendar:%s:%s', $this->tenantId, $this->academicYearId), + ); + } +} diff --git a/backend/src/Administration/Domain/Event/JourneePedagogiqueAjoutee.php b/backend/src/Administration/Domain/Event/JourneePedagogiqueAjoutee.php new file mode 100644 index 0000000..4e82dac --- /dev/null +++ b/backend/src/Administration/Domain/Event/JourneePedagogiqueAjoutee.php @@ -0,0 +1,49 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return Uuid::uuid5( + Uuid::NAMESPACE_DNS, + sprintf('school-calendar:%s:%s', $this->tenantId, $this->academicYearId), + ); + } +} diff --git a/backend/src/Administration/Domain/Exception/CalendrierDatesInvalidesException.php b/backend/src/Administration/Domain/Exception/CalendrierDatesInvalidesException.php new file mode 100644 index 0000000..fbb3b5c --- /dev/null +++ b/backend/src/Administration/Domain/Exception/CalendrierDatesInvalidesException.php @@ -0,0 +1,22 @@ +format('Y-m-d'), + $startDate->format('Y-m-d'), + )); + } +} diff --git a/backend/src/Administration/Domain/Exception/CalendrierEntreeNonTrouveeException.php b/backend/src/Administration/Domain/Exception/CalendrierEntreeNonTrouveeException.php new file mode 100644 index 0000000..d29aba7 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/CalendrierEntreeNonTrouveeException.php @@ -0,0 +1,21 @@ +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'); + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolCalendar/CalendarEntryId.php b/backend/src/Administration/Domain/Model/SchoolCalendar/CalendarEntryId.php new file mode 100644 index 0000000..d85431e --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolCalendar/CalendarEntryId.php @@ -0,0 +1,11 @@ + 'Jour férié', + self::VACATION => 'Vacances scolaires', + self::PEDAGOGICAL_DAY => 'Journée pédagogique', + self::BRIDGE => 'Pont', + self::EXCEPTIONAL_CLOSURE => 'Fermeture exceptionnelle', + }; + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolCalendar/SchoolCalendar.php b/backend/src/Administration/Domain/Model/SchoolCalendar/SchoolCalendar.php new file mode 100644 index 0000000..4153c52 --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolCalendar/SchoolCalendar.php @@ -0,0 +1,238 @@ + 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; + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolCalendar/SchoolCalendarRepository.php b/backend/src/Administration/Domain/Model/SchoolCalendar/SchoolCalendarRepository.php new file mode 100644 index 0000000..1efd27e --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolCalendar/SchoolCalendarRepository.php @@ -0,0 +1,27 @@ + + */ + 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'], + }; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/AddPedagogicalDayProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/AddPedagogicalDayProcessor.php new file mode 100644 index 0000000..8363b44 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/AddPedagogicalDayProcessor.php @@ -0,0 +1,85 @@ + + */ +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()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/ConfigureCalendarProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/ConfigureCalendarProcessor.php new file mode 100644 index 0000000..c0c9478 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/ConfigureCalendarProcessor.php @@ -0,0 +1,90 @@ + + */ +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()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/CalendarProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/CalendarProvider.php new file mode 100644 index 0000000..e44b961 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/CalendarProvider.php @@ -0,0 +1,66 @@ + + */ +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); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/IsSchoolDayProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/IsSchoolDayProvider.php new file mode 100644 index 0000000..41bd00d --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/IsSchoolDayProvider.php @@ -0,0 +1,84 @@ + + */ +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; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/CalendarEntryItem.php b/backend/src/Administration/Infrastructure/Api/Resource/CalendarEntryItem.php new file mode 100644 index 0000000..595b14b --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/CalendarEntryItem.php @@ -0,0 +1,30 @@ +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; + } +} diff --git a/backend/src/Administration/Infrastructure/Messaging/NotifyTeachersPedagogicalDayHandler.php b/backend/src/Administration/Infrastructure/Messaging/NotifyTeachersPedagogicalDayHandler.php new file mode 100644 index 0000000..706164c --- /dev/null +++ b/backend/src/Administration/Infrastructure/Messaging/NotifyTeachersPedagogicalDayHandler.php @@ -0,0 +1,88 @@ +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), + ]); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolCalendarRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolCalendarRepository.php new file mode 100644 index 0000000..d1719aa --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolCalendarRepository.php @@ -0,0 +1,133 @@ +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 $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, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySchoolCalendarRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySchoolCalendarRepository.php new file mode 100644 index 0000000..8d5dde5 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySchoolCalendarRepository.php @@ -0,0 +1,47 @@ + 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; + } +} diff --git a/backend/src/Administration/Infrastructure/Security/CalendarVoter.php b/backend/src/Administration/Infrastructure/Security/CalendarVoter.php new file mode 100644 index 0000000..0173b02 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/CalendarVoter.php @@ -0,0 +1,99 @@ + + */ +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; + } +} diff --git a/backend/src/Administration/Infrastructure/Service/FrenchPublicHolidaysCalculator.php b/backend/src/Administration/Infrastructure/Service/FrenchPublicHolidaysCalculator.php new file mode 100644 index 0000000..bc0abcb --- /dev/null +++ b/backend/src/Administration/Infrastructure/Service/FrenchPublicHolidaysCalculator.php @@ -0,0 +1,96 @@ + + */ + 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)); + } +} diff --git a/backend/src/Administration/Infrastructure/Service/JsonOfficialCalendarProvider.php b/backend/src/Administration/Infrastructure/Service/JsonOfficialCalendarProvider.php new file mode 100644 index 0000000..a4e9db1 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Service/JsonOfficialCalendarProvider.php @@ -0,0 +1,245 @@ +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, vacations: array>} + */ + 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, vacations: array>} $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> + */ + 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} $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; + } +} diff --git a/backend/templates/emails/pedagogical_day_notification.html.twig b/backend/templates/emails/pedagogical_day_notification.html.twig new file mode 100644 index 0000000..0ac2007 --- /dev/null +++ b/backend/templates/emails/pedagogical_day_notification.html.twig @@ -0,0 +1,90 @@ + + + + + + Journée pédagogique - Classeo + + + +
+

Classeo

+
+ +
+
+ ! +
+ +

Journée pédagogique programmée

+ +

Bonjour,

+ +

Une journée pédagogique a été ajoutée au calendrier scolaire.

+ +
+

Date : {{ date }}

+

Libellé : {{ label }}

+
+ +

Les cours ne seront pas assurés ce jour-là. Veuillez en tenir compte dans votre planification.

+
+ + + + diff --git a/backend/tests/Functional/Administration/Api/CalendarEndpointsTest.php b/backend/tests/Functional/Administration/Api/CalendarEndpointsTest.php new file mode 100644 index 0000000..b7cb2f9 --- /dev/null +++ b/backend/tests/Functional/Administration/Api/CalendarEndpointsTest.php @@ -0,0 +1,750 @@ +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); + } +} diff --git a/backend/tests/Functional/Administration/Application/ValidateHomeworkDueDateFunctionalTest.php b/backend/tests/Functional/Administration/Application/ValidateHomeworkDueDateFunctionalTest.php new file mode 100644 index 0000000..6f49c91 --- /dev/null +++ b/backend/tests/Functional/Administration/Application/ValidateHomeworkDueDateFunctionalTest.php @@ -0,0 +1,181 @@ +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); + } +} diff --git a/backend/tests/Integration/Administration/Infrastructure/Service/GouvFrCalendarApiTest.php b/backend/tests/Integration/Administration/Infrastructure/Service/GouvFrCalendarApiTest.php new file mode 100644 index 0000000..16447b8 --- /dev/null +++ b/backend/tests/Integration/Administration/Infrastructure/Service/GouvFrCalendarApiTest.php @@ -0,0 +1,165 @@ +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', + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/AddPedagogicalDay/AddPedagogicalDayHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/AddPedagogicalDay/AddPedagogicalDayHandlerTest.php new file mode 100644 index 0000000..c6cfeb7 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/AddPedagogicalDay/AddPedagogicalDayHandlerTest.php @@ -0,0 +1,167 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ConfigureCalendar/ConfigureCalendarHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ConfigureCalendar/ConfigureCalendarHandlerTest.php new file mode 100644 index 0000000..ea5536e --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ConfigureCalendar/ConfigureCalendarHandlerTest.php @@ -0,0 +1,200 @@ +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]); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/IsSchoolDay/IsSchoolDayHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/IsSchoolDay/IsSchoolDayHandlerTest.php new file mode 100644 index 0000000..9eb325c --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/IsSchoolDay/IsSchoolDayHandlerTest.php @@ -0,0 +1,176 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/ValidateHomeworkDueDate/ValidateHomeworkDueDateHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/ValidateHomeworkDueDate/ValidateHomeworkDueDateHandlerTest.php new file mode 100644 index 0000000..e193cdc --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/ValidateHomeworkDueDate/ValidateHomeworkDueDateHandlerTest.php @@ -0,0 +1,195 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/CalendarEntryTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/CalendarEntryTest.php new file mode 100644 index 0000000..77cd3e1 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/CalendarEntryTest.php @@ -0,0 +1,182 @@ +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', + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/CalendarEntryTypeTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/CalendarEntryTypeTest.php new file mode 100644 index 0000000..0d25ea4 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/CalendarEntryTypeTest.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/SchoolCalendarTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/SchoolCalendarTest.php new file mode 100644 index 0000000..d3247d4 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/SchoolCalendarTest.php @@ -0,0 +1,420 @@ +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, + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/SchoolZoneTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/SchoolZoneTest.php new file mode 100644 index 0000000..87a665b --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/SchoolZoneTest.php @@ -0,0 +1,58 @@ +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()); + } + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Messaging/NotifyTeachersPedagogicalDayHandlerTest.php b/backend/tests/Unit/Administration/Infrastructure/Messaging/NotifyTeachersPedagogicalDayHandlerTest.php new file mode 100644 index 0000000..7ffc3cd --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Messaging/NotifyTeachersPedagogicalDayHandlerTest.php @@ -0,0 +1,122 @@ +createMock(MailerInterface::class); + $twig = $this->createMock(Environment::class); + $userRepository = $this->createMock(UserRepository::class); + + $twig->method('render')->willReturn('notification'); + + $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('notification'); + + $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, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolCalendarRepositoryTest.php b/backend/tests/Unit/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolCalendarRepositoryTest.php new file mode 100644 index 0000000..99c17a1 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolCalendarRepositoryTest.php @@ -0,0 +1,219 @@ +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 + */ + 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', + ]; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/CalendarVoterTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/CalendarVoterTest.php new file mode 100644 index 0000000..ae19f34 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Security/CalendarVoterTest.php @@ -0,0 +1,146 @@ +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 + */ + 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 + */ + 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 + */ + 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 + */ + 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; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Service/FrenchPublicHolidaysCalculatorTest.php b/backend/tests/Unit/Administration/Infrastructure/Service/FrenchPublicHolidaysCalculatorTest.php new file mode 100644 index 0000000..e8fb1c8 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Service/FrenchPublicHolidaysCalculatorTest.php @@ -0,0 +1,92 @@ +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']); + } + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Service/JsonOfficialCalendarProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Service/JsonOfficialCalendarProviderTest.php new file mode 100644 index 0000000..e5aa669 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Service/JsonOfficialCalendarProviderTest.php @@ -0,0 +1,283 @@ +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]); + } +} diff --git a/frontend/e2e/calendar.spec.ts b/frontend/e2e/calendar.spec.ts new file mode 100644 index 0000000..e0a54b5 --- /dev/null +++ b/frontend/e2e/calendar.spec.ts @@ -0,0 +1,504 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-calendar-admin@example.com'; +const ADMIN_PASSWORD = 'CalendarTest123'; +const TEACHER_EMAIL = 'e2e-calendar-teacher@example.com'; +const TEACHER_PASSWORD = 'CalendarTeacher123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +// Dynamic future weekday for pedagogical day (avoids stale hardcoded dates) +const PED_DAY_DATE = (() => { + const d = new Date(); + d.setMonth(d.getMonth() + 2); + while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1); + return d.toISOString().split('T')[0]; +})(); +const PED_DAY_LABEL = 'Formation enseignants'; + +// Serial: empty state must be verified before import, display after import +test.describe.configure({ mode: 'serial' }); + +test.describe('Calendar Management (Story 2.11)', () => { + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // Create admin and teacher test users + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + // Clean calendar entries to ensure empty state + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'" 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Table might not have data yet + } + }); + + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + async function loginAsTeacher(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TEACHER_EMAIL); + await page.locator('#password').fill(TEACHER_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + // ============================================================================ + // Navigation (AC1) + // ============================================================================ + test.describe('Navigation', () => { + test('[P1] can access calendar page from admin navigation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + await page.getByRole('link', { name: /calendrier/i }).click(); + + await expect(page).toHaveURL(/\/admin\/calendar/); + await expect( + page.getByRole('heading', { name: /calendrier scolaire/i }) + ).toBeVisible(); + }); + + test('[P1] can access calendar page directly', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await expect(page).toHaveURL(/\/admin\/calendar/); + await expect( + page.getByRole('heading', { name: /calendrier scolaire/i }) + ).toBeVisible(); + }); + }); + + // ============================================================================ + // Authorization (AC1) + // ============================================================================ + test.describe('Authorization', () => { + test('[P0] teacher is redirected away from calendar admin', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + // Admin layout redirects non-admin roles to /dashboard + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + }); + }); + + // ============================================================================ + // Empty State (AC1) + // ============================================================================ + test.describe('Empty State', () => { + test('[P1] shows empty state when no calendar configured', async ({ page }) => { + // Clean up right before test to avoid race conditions + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'" 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Ignore cleanup errors + } + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await expect( + page.getByRole('heading', { name: /calendrier scolaire/i }) + ).toBeVisible(); + await expect(page.getByText(/aucun calendrier configuré/i)).toBeVisible({ + timeout: 10000 + }); + await expect( + page.getByRole('button', { name: /importer le calendrier officiel/i }) + ).toBeVisible(); + }); + + test('[P1] displays three year selector tabs', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + const tabs = page.locator('.year-tab'); + await expect(tabs).toHaveCount(3); + }); + }); + + // ============================================================================ + // Import Official Calendar (AC2) + // ============================================================================ + test.describe('Import Official Calendar', () => { + test('[P1] import button opens modal with zone selector', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await expect(dialog.getByText(/importer le calendrier officiel/i)).toBeVisible(); + }); + + test('[P2] import modal shows zones A, B, C', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + await expect(dialog.getByText('Zone A')).toBeVisible(); + await expect(dialog.getByText('Zone B')).toBeVisible(); + await expect(dialog.getByText('Zone C')).toBeVisible(); + }); + + test('[P2] can cancel import modal', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + await dialog.getByRole('button', { name: /annuler/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + + test('[P2] modal closes on Escape key', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Press Escape to dismiss the modal + await page.keyboard.press('Escape'); + + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + + test('[P0] shows error when import API fails', async ({ page }) => { + await loginAsAdmin(page); + + // Intercept import POST to simulate server error + await page.route('**/calendar/import-official', (route) => + route.fulfill({ + status: 500, + contentType: 'application/ld+json', + body: JSON.stringify({ 'hydra:description': 'Erreur serveur interne' }) + }) + ); + + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Submit import (intercepted → 500) + await dialog.getByRole('button', { name: /^importer$/i }).click(); + + // Close modal to reveal error message on the page + await dialog.getByRole('button', { name: /annuler/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + + // Error alert should be visible + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 5000 }); + }); + + test('[P0] importing zone A populates calendar with entries', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + // Wait for empty state to be displayed + await expect(page.getByText(/aucun calendrier configuré/i)).toBeVisible({ + timeout: 10000 + }); + + // Open import modal from empty state CTA + await page.getByRole('button', { name: /importer le calendrier officiel/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Zone A is pre-selected, click import + await dialog.getByRole('button', { name: /^importer$/i }).click(); + + // Modal should close + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Success message + await expect(page.getByText(/calendrier officiel importé/i)).toBeVisible({ + timeout: 10000 + }); + }); + }); + + // ============================================================================ + // Calendar Display after Import (AC1, AC3, AC4) + // ============================================================================ + test.describe('Calendar Display', () => { + test('[P1] shows zone badge after import', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await expect(page.locator('.zone-badge')).toContainText('Zone A', { timeout: 10000 }); + }); + + test('[P0] shows holidays section with actual entries', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + // Verify the section heading exists with a count > 0 + await expect( + page.getByRole('heading', { name: /jours fériés/i }) + ).toBeVisible({ timeout: 10000 }); + + // Verify specific imported holiday entries are displayed + await expect(page.getByText('Toussaint', { exact: true })).toBeVisible(); + await expect(page.getByText('Noël', { exact: true })).toBeVisible(); + + // Verify entry cards exist (not just the heading) + const holidaySection = page.locator('.entry-section').filter({ + has: page.getByRole('heading', { name: /jours fériés/i }) + }); + await expect(holidaySection.locator('.entry-card').first()).toBeVisible(); + }); + + test('[P1] shows vacations section with specific entries', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await expect( + page.getByRole('heading', { name: /vacances scolaires/i }) + ).toBeVisible({ timeout: 10000 }); + + // Verify specific vacation entry names from Zone A official data + await expect(page.getByText('Hiver')).toBeVisible(); + await expect(page.getByText('Printemps')).toBeVisible(); + + // Verify entry cards exist within the vacation section + const vacationSection = page.locator('.entry-section').filter({ + has: page.getByRole('heading', { name: /vacances scolaires/i }) + }); + await expect(vacationSection.locator('.entry-card').first()).toBeVisible(); + const cardCount = await vacationSection.locator('.entry-card').count(); + expect(cardCount).toBeGreaterThanOrEqual(4); + }); + + test('[P2] shows legend with color indicators', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + // Wait for calendar to load + await expect( + page.getByRole('heading', { name: /jours fériés/i }) + ).toBeVisible({ timeout: 10000 }); + + await expect( + page.locator('.legend-item').filter({ hasText: /jours fériés/i }) + ).toBeVisible(); + await expect( + page.locator('.legend-item').filter({ hasText: /vacances/i }) + ).toBeVisible(); + await expect( + page.locator('.legend-item').filter({ hasText: /journées pédagogiques/i }) + ).toBeVisible(); + }); + + test('[P2] can switch between year tabs', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + // Wait for calendar to load + await expect( + page.getByRole('heading', { name: /jours fériés/i }) + ).toBeVisible({ timeout: 10000 }); + + const tabs = page.locator('.year-tab'); + + // Middle tab (current year) should be active by default + await expect(tabs.nth(1)).toHaveClass(/year-tab-active/); + + // Click next year tab + await tabs.nth(2).click(); + await expect(tabs.nth(2)).toHaveClass(/year-tab-active/, { timeout: 5000 }); + + // Click previous year tab + await tabs.nth(0).click(); + await expect(tabs.nth(0)).toHaveClass(/year-tab-active/, { timeout: 5000 }); + }); + }); + + // ============================================================================ + // Pedagogical Day (AC5) + // ============================================================================ + test.describe('Pedagogical Day', () => { + test('[P1] add pedagogical day button is visible', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await expect( + page.getByRole('button', { name: /ajouter journée pédagogique/i }) + ).toBeVisible(); + }); + + test('[P1] pedagogical day modal opens with form fields', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Form fields + await expect(dialog.locator('#ped-date')).toBeVisible(); + await expect(dialog.locator('#ped-label')).toBeVisible(); + await expect(dialog.locator('#ped-description')).toBeVisible(); + }); + + test('[P2] can cancel pedagogical day modal', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + await dialog.getByRole('button', { name: /annuler/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + + test('[P1] submit button disabled when fields empty', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + const submitButton = dialog.getByRole('button', { name: /^ajouter$/i }); + + // Both fields empty → button disabled + await expect(submitButton).toBeDisabled(); + + // Fill only date → still disabled (label missing) + await dialog.locator('#ped-date').fill(PED_DAY_DATE); + await expect(submitButton).toBeDisabled(); + + // Clear date, fill only label → still disabled (date missing) + await dialog.locator('#ped-date').fill(''); + await dialog.locator('#ped-label').fill(PED_DAY_LABEL); + await expect(submitButton).toBeDisabled(); + + // Fill both date and label → button enabled + await dialog.locator('#ped-date').fill(PED_DAY_DATE); + await expect(submitButton).toBeEnabled(); + }); + + test('[P0] can add a pedagogical day successfully', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + // Wait for calendar to load + await expect( + page.getByRole('heading', { name: /jours fériés/i }) + ).toBeVisible({ timeout: 10000 }); + + // Open modal + await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Fill form with dynamic future date + await dialog.locator('#ped-date').fill(PED_DAY_DATE); + await dialog.locator('#ped-label').fill(PED_DAY_LABEL); + await dialog.locator('#ped-description').fill('Journée de formation continue'); + + // Submit + await dialog.getByRole('button', { name: /^ajouter$/i }).click(); + + // Modal should close + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Success message + await expect( + page.getByText(/journée pédagogique ajoutée/i) + ).toBeVisible({ timeout: 10000 }); + }); + + test('[P1] added pedagogical day appears in list', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + // Wait for calendar to load + await expect( + page.getByRole('heading', { name: /jours fériés/i }) + ).toBeVisible({ timeout: 10000 }); + + // Pedagogical day section should exist with the added day + await expect( + page.getByRole('heading', { name: /journées pédagogiques/i }) + ).toBeVisible(); + await expect(page.getByText(PED_DAY_LABEL)).toBeVisible(); + }); + + test('[P2] pedagogical day shows distinct amber styling', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/calendar`); + + // Wait for calendar to load + await expect( + page.getByRole('heading', { name: /journées pédagogiques/i }) + ).toBeVisible({ timeout: 10000 }); + + // Verify the pedagogical day section has an amber indicator dot + const pedSection = page.locator('.entry-section').filter({ + has: page.getByRole('heading', { name: /journées pédagogiques/i }) + }); + const sectionDot = pedSection.locator('.section-dot'); + await expect(sectionDot).toBeVisible(); + await expect(sectionDot).toHaveCSS('background-color', 'rgb(245, 158, 11)'); + }); + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 165c036..b443b8f 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -77,7 +77,8 @@ export default tseslint.config( Event: 'readonly', SubmitEvent: 'readonly', fetch: 'readonly', - HTMLDivElement: 'readonly', + HTMLElement: 'readonly', + HTMLDivElement: 'readonly', setInterval: 'readonly', clearInterval: 'readonly', URLSearchParams: 'readonly', diff --git a/frontend/src/lib/components/organisms/CalendarView/CalendarView.svelte b/frontend/src/lib/components/organisms/CalendarView/CalendarView.svelte new file mode 100644 index 0000000..7b96ed5 --- /dev/null +++ b/frontend/src/lib/components/organisms/CalendarView/CalendarView.svelte @@ -0,0 +1,472 @@ + + +
+
+ + + +
+ +
+
+ {#each DAYS as day} +
{day}
+ {/each} +
+ + {#each calendarGrid() as row} +
+ {#each row as cell} + {#if cell === null} +
+ {:else} +
0} + role="gridcell" + aria-label="{cell.day} {MONTHS[month]} {year}{cell.entries.length > 0 ? `, ${cell.entries.length} événement${cell.entries.length > 1 ? 's' : ''}` : ''}" + onmouseenter={(e) => showTooltip(e, cell.entries)} + onmouseleave={hideTooltip} + > + {cell.day} + {#if cell.entries.length > 0} +
+ {#each cell.entries.slice(0, 3) as entry} + + {/each} + {#if cell.entries.length > 3} + +{cell.entries.length - 3} + {/if} +
+ {/if} +
+ {/if} + {/each} +
+ {/each} +
+ + {#if activeTypes().length > 0} +
+ {#each activeTypes() as type} + + + {TYPE_LABELS[type] ?? type} + + {/each} +
+ {/if} +
+ +{#if tooltip} + +{/if} + + diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index 876413b..c32795f 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -56,6 +56,11 @@ Périodes scolaires Trimestres et semestres + + 🗓️ + Calendrier scolaire + Fériés et vacances + 🎓 Pédagogie diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index bae5d4d..b24edd3 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -27,6 +27,7 @@ { href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive }, { href: '/admin/replacements', label: 'Remplacements', isActive: () => isReplacementsActive }, { href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive }, + { href: '/admin/calendar', label: 'Calendrier', isActive: () => isCalendarActive }, { href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive } ]; @@ -78,6 +79,7 @@ const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods')); const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments')); const isReplacementsActive = $derived(page.url.pathname.startsWith('/admin/replacements')); + const isCalendarActive = $derived(page.url.pathname.startsWith('/admin/calendar')); const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy')); const currentSectionLabel = $derived.by(() => { diff --git a/frontend/src/routes/admin/calendar/+page.svelte b/frontend/src/routes/admin/calendar/+page.svelte new file mode 100644 index 0000000..2d86ecc --- /dev/null +++ b/frontend/src/routes/admin/calendar/+page.svelte @@ -0,0 +1,1010 @@ + + + + Calendrier scolaire — Classeo + + + + +{#if successMessage} +
{successMessage}
+{/if} + +{#if error} +
{error}
+{/if} + +{#if isLoading} +
+
+

Chargement du calendrier...

+
+{:else if !hasCalendar} +
+
📅
+

Aucun calendrier configuré

+

Importez le calendrier officiel pour commencer à configurer les jours fériés et vacances scolaires.

+ +
+{:else} + {#if calendar?.zone} +
Zone {calendar.zone}
+ {/if} + +
+ + +
+ +
+ + Jours fériés + + + Vacances + + + Journées pédagogiques + + {#if otherEntries.length > 0} + + Autres + + {/if} +
+ + {#if viewMode === 'list'} + {#if holidays.length > 0} +
+

+ + Jours fériés ({holidays.length}) +

+
+ {#each holidays as entry} +
+ + +
+ {/each} +
+
+ {/if} + + {#if vacations.length > 0} +
+

+ + Vacances scolaires ({vacations.length}) +

+
+ {#each vacations as entry} +
+ + + {#if entry.description} +
{entry.description}
+ {/if} +
+ {/each} +
+
+ {/if} + + {#if pedagogicalDays.length > 0} +
+

+ + Journées pédagogiques ({pedagogicalDays.length}) +

+
+ {#each pedagogicalDays as entry} +
+ + + {#if entry.description} +
{entry.description}
+ {/if} +
+ {/each} +
+
+ {/if} + + {#if otherEntries.length > 0} +
+

+ + Autres ({otherEntries.length}) +

+
+ {#each otherEntries as entry} +
+ + + {entryTypeLabel(entry.type)} +
+ {/each} +
+
+ {/if} + {:else} + + {/if} +{/if} + + +{#if showImportModal} + +{/if} + + +{#if showPedagogicalModal} + +{/if} + +