From e06fd5424d608d0cc7625fa8c30552d3737da79e Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 18 Feb 2026 10:16:28 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Configurer=20les=20jours=20f=C3=A9ri?= =?UTF-8?q?=C3=A9s=20et=20vacances=20du=20calendrier=20scolaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les administrateurs d'établissement avaient besoin de gérer le calendrier scolaire (FR80) pour que l'EDT et les devoirs respectent automatiquement les jours non travaillés. Sans cette configuration centralisée, chaque module devait gérer indépendamment les contraintes de dates. Le calendrier s'appuie sur l'API data.education.gouv.fr pour importer les vacances officielles par zone (A/B/C) et calcule les 11 jours fériés français (dont les fêtes mobiles liées à Pâques). Les enseignants sont notifiés par email lors de l'ajout d'une journée pédagogique. Un query IsSchoolDay et une validation des dates d'échéance de devoirs permettent aux autres modules de s'intégrer sans couplage direct. --- backend/config/services.yaml | 11 + backend/migrations/Version20260217093243.php | 48 + backend/migrations/Version20260217231503.php | 30 + .../AddPedagogicalDayCommand.php | 20 + .../AddPedagogicalDayHandler.php | 67 ++ .../ConfigureCalendarCommand.php | 19 + .../ConfigureCalendarHandler.php | 46 + .../Port/OfficialCalendarProvider.php | 35 + .../Query/IsSchoolDay/IsSchoolDayHandler.php | 56 + .../Query/IsSchoolDay/IsSchoolDayQuery.php | 15 + .../DueDateValidationResult.php | 28 + .../ValidateHomeworkDueDateHandler.php | 68 ++ .../ValidateHomeworkDueDateQuery.php | 15 + .../Domain/Event/CalendrierConfigure.php | 47 + .../Event/JourneePedagogiqueAjoutee.php | 49 + .../CalendrierDatesInvalidesException.php | 22 + .../CalendrierEntreeNonTrouveeException.php | 21 + .../CalendrierLabelInvalideException.php | 26 + .../CalendrierNonTrouveException.php | 23 + .../Model/SchoolCalendar/CalendarEntry.php | 64 ++ .../Model/SchoolCalendar/CalendarEntryId.php | 11 + .../SchoolCalendar/CalendarEntryType.php | 30 + .../Model/SchoolCalendar/SchoolCalendar.php | 238 ++++ .../SchoolCalendarRepository.php | 27 + .../Model/SchoolCalendar/SchoolZone.php | 27 + .../Processor/AddPedagogicalDayProcessor.php | 85 ++ .../Processor/ConfigureCalendarProcessor.php | 90 ++ .../Api/Provider/CalendarProvider.php | 66 ++ .../Api/Provider/IsSchoolDayProvider.php | 84 ++ .../Api/Resource/CalendarEntryItem.php | 30 + .../Api/Resource/CalendarResource.php | 111 ++ .../NotifyTeachersPedagogicalDayHandler.php | 88 ++ .../DoctrineSchoolCalendarRepository.php | 133 +++ .../InMemorySchoolCalendarRepository.php | 47 + .../Infrastructure/Security/CalendarVoter.php | 99 ++ .../FrenchPublicHolidaysCalculator.php | 96 ++ .../Service/JsonOfficialCalendarProvider.php | 245 ++++ .../pedagogical_day_notification.html.twig | 90 ++ .../Api/CalendarEndpointsTest.php | 750 ++++++++++++ .../ValidateHomeworkDueDateFunctionalTest.php | 181 +++ .../Service/GouvFrCalendarApiTest.php | 165 +++ .../AddPedagogicalDayHandlerTest.php | 167 +++ .../ConfigureCalendarHandlerTest.php | 200 ++++ .../IsSchoolDay/IsSchoolDayHandlerTest.php | 176 +++ .../ValidateHomeworkDueDateHandlerTest.php | 195 ++++ .../SchoolCalendar/CalendarEntryTest.php | 182 +++ .../SchoolCalendar/CalendarEntryTypeTest.php | 40 + .../SchoolCalendar/SchoolCalendarTest.php | 420 +++++++ .../Model/SchoolCalendar/SchoolZoneTest.php | 58 + ...otifyTeachersPedagogicalDayHandlerTest.php | 122 ++ .../DoctrineSchoolCalendarRepositoryTest.php | 219 ++++ .../Security/CalendarVoterTest.php | 146 +++ .../FrenchPublicHolidaysCalculatorTest.php | 92 ++ .../JsonOfficialCalendarProviderTest.php | 283 +++++ frontend/e2e/calendar.spec.ts | 504 ++++++++ frontend/eslint.config.js | 3 +- .../CalendarView/CalendarView.svelte | 472 ++++++++ .../organisms/Dashboard/DashboardAdmin.svelte | 5 + frontend/src/routes/admin/+layout.svelte | 2 + .../src/routes/admin/calendar/+page.svelte | 1010 +++++++++++++++++ 60 files changed, 7698 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/Version20260217093243.php create mode 100644 backend/migrations/Version20260217231503.php create mode 100644 backend/src/Administration/Application/Command/AddPedagogicalDay/AddPedagogicalDayCommand.php create mode 100644 backend/src/Administration/Application/Command/AddPedagogicalDay/AddPedagogicalDayHandler.php create mode 100644 backend/src/Administration/Application/Command/ConfigureCalendar/ConfigureCalendarCommand.php create mode 100644 backend/src/Administration/Application/Command/ConfigureCalendar/ConfigureCalendarHandler.php create mode 100644 backend/src/Administration/Application/Port/OfficialCalendarProvider.php create mode 100644 backend/src/Administration/Application/Query/IsSchoolDay/IsSchoolDayHandler.php create mode 100644 backend/src/Administration/Application/Query/IsSchoolDay/IsSchoolDayQuery.php create mode 100644 backend/src/Administration/Application/Query/ValidateHomeworkDueDate/DueDateValidationResult.php create mode 100644 backend/src/Administration/Application/Query/ValidateHomeworkDueDate/ValidateHomeworkDueDateHandler.php create mode 100644 backend/src/Administration/Application/Query/ValidateHomeworkDueDate/ValidateHomeworkDueDateQuery.php create mode 100644 backend/src/Administration/Domain/Event/CalendrierConfigure.php create mode 100644 backend/src/Administration/Domain/Event/JourneePedagogiqueAjoutee.php create mode 100644 backend/src/Administration/Domain/Exception/CalendrierDatesInvalidesException.php create mode 100644 backend/src/Administration/Domain/Exception/CalendrierEntreeNonTrouveeException.php create mode 100644 backend/src/Administration/Domain/Exception/CalendrierLabelInvalideException.php create mode 100644 backend/src/Administration/Domain/Exception/CalendrierNonTrouveException.php create mode 100644 backend/src/Administration/Domain/Model/SchoolCalendar/CalendarEntry.php create mode 100644 backend/src/Administration/Domain/Model/SchoolCalendar/CalendarEntryId.php create mode 100644 backend/src/Administration/Domain/Model/SchoolCalendar/CalendarEntryType.php create mode 100644 backend/src/Administration/Domain/Model/SchoolCalendar/SchoolCalendar.php create mode 100644 backend/src/Administration/Domain/Model/SchoolCalendar/SchoolCalendarRepository.php create mode 100644 backend/src/Administration/Domain/Model/SchoolCalendar/SchoolZone.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/AddPedagogicalDayProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/ConfigureCalendarProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/CalendarProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/IsSchoolDayProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/CalendarEntryItem.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/CalendarResource.php create mode 100644 backend/src/Administration/Infrastructure/Messaging/NotifyTeachersPedagogicalDayHandler.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolCalendarRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySchoolCalendarRepository.php create mode 100644 backend/src/Administration/Infrastructure/Security/CalendarVoter.php create mode 100644 backend/src/Administration/Infrastructure/Service/FrenchPublicHolidaysCalculator.php create mode 100644 backend/src/Administration/Infrastructure/Service/JsonOfficialCalendarProvider.php create mode 100644 backend/templates/emails/pedagogical_day_notification.html.twig create mode 100644 backend/tests/Functional/Administration/Api/CalendarEndpointsTest.php create mode 100644 backend/tests/Functional/Administration/Application/ValidateHomeworkDueDateFunctionalTest.php create mode 100644 backend/tests/Integration/Administration/Infrastructure/Service/GouvFrCalendarApiTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/AddPedagogicalDay/AddPedagogicalDayHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/ConfigureCalendar/ConfigureCalendarHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/IsSchoolDay/IsSchoolDayHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/ValidateHomeworkDueDate/ValidateHomeworkDueDateHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/CalendarEntryTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/CalendarEntryTypeTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/SchoolCalendarTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/SchoolCalendar/SchoolZoneTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Messaging/NotifyTeachersPedagogicalDayHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Persistence/Doctrine/DoctrineSchoolCalendarRepositoryTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Security/CalendarVoterTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Service/FrenchPublicHolidaysCalculatorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Service/JsonOfficialCalendarProviderTest.php create mode 100644 frontend/e2e/calendar.spec.ts create mode 100644 frontend/src/lib/components/organisms/CalendarView/CalendarView.svelte create mode 100644 frontend/src/routes/admin/calendar/+page.svelte 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} + +