From f19d0ae3efb980aebf1dd875375f7fa071885cea Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Fri, 6 Feb 2026 12:00:29 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Gestion=20des=20p=C3=A9riodes=20scolair?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'administration d'un établissement nécessite de découper l'année scolaire en trimestres ou semestres avant de pouvoir saisir les notes et générer les bulletins. Ce module permet de configurer les périodes par année scolaire (current/previous/next résolus en UUID v5 déterministes), de modifier les dates individuelles avec validation anti-chevauchement, et de consulter la période en cours avec le décompte des jours restants. Les dates par défaut de février s'adaptent aux années bissextiles. Le repository utilise UPSERT transactionnel pour garantir l'intégrité lors du changement de mode (trimestres ↔ semestres). Les domain events de Subject sont étendus pour couvrir toutes les mutations (code, couleur, description) en plus du renommage. --- Makefile | 4 +- backend/config/services.yaml | 8 + backend/migrations/Version20260205100002.php | 51 + .../ConfigurePeriodsCommand.php | 23 + .../ConfigurePeriodsHandler.php | 63 ++ .../UpdatePeriod/UpdatePeriodCommand.php | 21 + .../UpdatePeriod/UpdatePeriodHandler.php | 98 ++ .../Port/GradeExistenceChecker.php | 23 + .../Query/GetPeriods/GetPeriodsHandler.php | 57 ++ .../Query/GetPeriods/GetPeriodsQuery.php | 17 + .../Query/GetPeriods/PeriodDto.php | 38 + .../Query/GetPeriods/PeriodsResultDto.php | 21 + .../HasGradesInPeriodHandler.php | 34 + .../HasGradesInPeriodQuery.php | 21 + .../Domain/Event/MatiereModifiee.php | 6 +- .../Domain/Event/PeriodeModifiee.php | 39 + .../Domain/Event/PeriodesConfigurees.php | 40 + .../Exception/InvalidPeriodCountException.php | 22 + .../Exception/InvalidPeriodDatesException.php | 22 + .../Exception/PeriodeAvecNotesException.php | 20 + .../Exception/PeriodeNonTrouveeException.php | 21 + .../PeriodesDejaConfigureesException.php | 15 + .../PeriodesNonConfigureesException.php | 15 + .../Exception/PeriodsCoverageGapException.php | 21 + .../Exception/PeriodsOverlapException.php | 21 + .../Model/AcademicYear/AcademicPeriod.php | 60 ++ .../Model/AcademicYear/DefaultPeriods.php | 63 ++ .../AcademicYear/PeriodConfiguration.php | 120 +++ .../Domain/Model/AcademicYear/PeriodType.php | 25 + .../Domain/Model/Subject/Subject.php | 35 +- .../PeriodConfigurationRepository.php | 16 + .../Processor/ConfigurePeriodsProcessor.php | 97 ++ .../Api/Processor/CreateSubjectProcessor.php | 13 +- .../Api/Processor/UpdatePeriodProcessor.php | 113 +++ .../Api/Processor/UpdateSubjectProcessor.php | 13 +- .../Api/Provider/PeriodsProvider.php | 96 ++ .../Provider/SubjectCollectionProvider.php | 19 +- .../Api/Provider/SubjectItemProvider.php | 12 +- .../Api/Resource/PeriodItem.php | 28 + .../Api/Resource/PeriodResource.php | 78 ++ .../Api/Resource/SubjectResource.php | 40 + .../Doctrine/DoctrineClassRepository.php | 48 +- .../DoctrinePeriodConfigurationRepository.php | 119 +++ .../Doctrine/DoctrineSubjectRepository.php | 46 +- .../InMemoryPeriodConfigurationRepository.php | 31 + .../InMemory/InMemorySubjectRepository.php | 4 +- .../Infrastructure/Security/PeriodVoter.php | 100 ++ .../Service/CurrentAcademicYearResolver.php | 73 ++ .../Service/NoOpGradeExistenceChecker.php | 27 + .../Api/PeriodsEndpointsTest.php | 162 +++ .../ConfigurePeriodsHandlerTest.php | 139 +++ .../UpdatePeriod/UpdatePeriodHandlerTest.php | 184 ++++ .../GetPeriods/GetPeriodsHandlerTest.php | 111 ++ .../Model/AcademicYear/AcademicPeriodTest.php | 127 +++ .../Model/AcademicYear/DefaultPeriodsTest.php | 71 ++ .../AcademicYear/PeriodConfigurationTest.php | 150 +++ .../Model/AcademicYear/PeriodTypeTest.php | 31 + .../Domain/Model/Subject/SubjectTest.php | 49 +- .../ConfigurePeriodsProcessorTest.php | 188 ++++ .../Processor/UpdatePeriodProcessorTest.php | 212 ++++ .../Api/Provider/PeriodsProviderTest.php | 223 ++++ .../CurrentAcademicYearResolverTest.php | 214 ++++ frontend/e2e/classes.spec.ts | 4 +- frontend/e2e/periods.spec.ts | 312 ++++++ .../organisms/Dashboard/DashboardAdmin.svelte | 8 +- frontend/src/routes/admin/+layout.svelte | 2 + frontend/src/routes/admin/+page.svelte | 171 ++++ .../admin/academic-year/periods/+page.svelte | 958 ++++++++++++++++++ scripts/hooks/pre-push | 9 +- 69 files changed, 5201 insertions(+), 121 deletions(-) create mode 100644 backend/migrations/Version20260205100002.php create mode 100644 backend/src/Administration/Application/Command/ConfigurePeriods/ConfigurePeriodsCommand.php create mode 100644 backend/src/Administration/Application/Command/ConfigurePeriods/ConfigurePeriodsHandler.php create mode 100644 backend/src/Administration/Application/Command/UpdatePeriod/UpdatePeriodCommand.php create mode 100644 backend/src/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandler.php create mode 100644 backend/src/Administration/Application/Port/GradeExistenceChecker.php create mode 100644 backend/src/Administration/Application/Query/GetPeriods/GetPeriodsHandler.php create mode 100644 backend/src/Administration/Application/Query/GetPeriods/GetPeriodsQuery.php create mode 100644 backend/src/Administration/Application/Query/GetPeriods/PeriodDto.php create mode 100644 backend/src/Administration/Application/Query/GetPeriods/PeriodsResultDto.php create mode 100644 backend/src/Administration/Application/Query/HasGradesInPeriod/HasGradesInPeriodHandler.php create mode 100644 backend/src/Administration/Application/Query/HasGradesInPeriod/HasGradesInPeriodQuery.php create mode 100644 backend/src/Administration/Domain/Event/PeriodeModifiee.php create mode 100644 backend/src/Administration/Domain/Event/PeriodesConfigurees.php create mode 100644 backend/src/Administration/Domain/Exception/InvalidPeriodCountException.php create mode 100644 backend/src/Administration/Domain/Exception/InvalidPeriodDatesException.php create mode 100644 backend/src/Administration/Domain/Exception/PeriodeAvecNotesException.php create mode 100644 backend/src/Administration/Domain/Exception/PeriodeNonTrouveeException.php create mode 100644 backend/src/Administration/Domain/Exception/PeriodesDejaConfigureesException.php create mode 100644 backend/src/Administration/Domain/Exception/PeriodesNonConfigureesException.php create mode 100644 backend/src/Administration/Domain/Exception/PeriodsCoverageGapException.php create mode 100644 backend/src/Administration/Domain/Exception/PeriodsOverlapException.php create mode 100644 backend/src/Administration/Domain/Model/AcademicYear/AcademicPeriod.php create mode 100644 backend/src/Administration/Domain/Model/AcademicYear/DefaultPeriods.php create mode 100644 backend/src/Administration/Domain/Model/AcademicYear/PeriodConfiguration.php create mode 100644 backend/src/Administration/Domain/Model/AcademicYear/PeriodType.php create mode 100644 backend/src/Administration/Domain/Repository/PeriodConfigurationRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/ConfigurePeriodsProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/PeriodsProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/PeriodItem.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/PeriodResource.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrinePeriodConfigurationRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryPeriodConfigurationRepository.php create mode 100644 backend/src/Administration/Infrastructure/Security/PeriodVoter.php create mode 100644 backend/src/Administration/Infrastructure/Service/CurrentAcademicYearResolver.php create mode 100644 backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php create mode 100644 backend/tests/Functional/Administration/Api/PeriodsEndpointsTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/ConfigurePeriods/ConfigurePeriodsHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/GetPeriods/GetPeriodsHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/AcademicYear/AcademicPeriodTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/AcademicYear/DefaultPeriodsTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/AcademicYear/PeriodConfigurationTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/AcademicYear/PeriodTypeTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigurePeriodsProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Provider/PeriodsProviderTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Service/CurrentAcademicYearResolverTest.php create mode 100644 frontend/e2e/periods.spec.ts create mode 100644 frontend/src/routes/admin/+page.svelte create mode 100644 frontend/src/routes/admin/academic-year/periods/+page.svelte diff --git a/Makefile b/Makefile index 738c433..b866bb2 100644 --- a/Makefile +++ b/Makefile @@ -214,7 +214,7 @@ ci: ## Lancer TOUS les tests et checks (comme en CI) # ============================================================================= .PHONY: setup-hooks -setup-hooks: ## Installer les git hooks (pre-push: make ci && make e2e) +setup-hooks: ## Installer les git hooks (pre-push: make ci) @echo "Installation des git hooks..." @cp scripts/hooks/pre-push .git/hooks/pre-push @chmod +x .git/hooks/pre-push @@ -237,7 +237,7 @@ check-tenants: ## Vérifier que les tenants répondent # ============================================================================= .PHONY: install -install: up jwt-keys migrate warmup ## Installation complète après clone +install: up jwt-keys setup-hooks migrate warmup ## Installation complète après clone .PHONY: migrate migrate: ## Exécuter les migrations Doctrine diff --git a/backend/config/services.yaml b/backend/config/services.yaml index ad01e66..3e2a3fc 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -134,6 +134,14 @@ services: App\Administration\Domain\Repository\SubjectRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSubjectRepository + # Period Configuration Repository (Story 2.3 - Gestion des périodes) + App\Administration\Domain\Repository\PeriodConfigurationRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrinePeriodConfigurationRepository + + # GradeExistenceChecker (stub until Notes module exists) + App\Administration\Application\Port\GradeExistenceChecker: + alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker + # GeoLocation Service (null implementation - no geolocation) App\Administration\Application\Port\GeoLocationService: alias: App\Administration\Infrastructure\Service\NullGeoLocationService diff --git a/backend/migrations/Version20260205100002.php b/backend/migrations/Version20260205100002.php new file mode 100644 index 0000000..7fcb9c8 --- /dev/null +++ b/backend/migrations/Version20260205100002.php @@ -0,0 +1,51 @@ +addSql(<<<'SQL' + CREATE TABLE academic_periods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + academic_year_id UUID NOT NULL, + period_type VARCHAR(20) NOT NULL, + sequence INT NOT NULL, + label VARCHAR(20) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $this->addSql('CREATE INDEX idx_academic_periods_tenant_id ON academic_periods(tenant_id)'); + $this->addSql('CREATE INDEX idx_academic_periods_year ON academic_periods(academic_year_id)'); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX idx_academic_periods_unique_sequence + ON academic_periods (tenant_id, academic_year_id, sequence) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS academic_periods'); + } +} diff --git a/backend/src/Administration/Application/Command/ConfigurePeriods/ConfigurePeriodsCommand.php b/backend/src/Administration/Application/Command/ConfigurePeriods/ConfigurePeriodsCommand.php new file mode 100644 index 0000000..ae157a2 --- /dev/null +++ b/backend/src/Administration/Application/Command/ConfigurePeriods/ConfigurePeriodsCommand.php @@ -0,0 +1,23 @@ +tenantId); + $academicYearId = AcademicYearId::fromString($command->academicYearId); + $periodType = PeriodType::from($command->periodType); + + $existing = $this->repository->findByAcademicYear($tenantId, $academicYearId); + if ($existing !== null) { + throw PeriodesDejaConfigureesException::pourAnnee($command->academicYearId); + } + + $configuration = DefaultPeriods::forType($periodType, $command->startYear); + + $this->repository->save($tenantId, $academicYearId, $configuration); + + $this->eventBus->dispatch(new PeriodesConfigurees( + academicYearId: $academicYearId, + tenantId: $tenantId, + periodType: $periodType, + periodCount: count($configuration->periods), + occurredOn: $this->clock->now(), + )); + + return $configuration; + } +} diff --git a/backend/src/Administration/Application/Command/UpdatePeriod/UpdatePeriodCommand.php b/backend/src/Administration/Application/Command/UpdatePeriod/UpdatePeriodCommand.php new file mode 100644 index 0000000..04811ae --- /dev/null +++ b/backend/src/Administration/Application/Command/UpdatePeriod/UpdatePeriodCommand.php @@ -0,0 +1,21 @@ +tenantId); + $academicYearId = AcademicYearId::fromString($command->academicYearId); + + $existing = $this->repository->findByAcademicYear($tenantId, $academicYearId); + if ($existing === null) { + throw PeriodesNonConfigureesException::pourAnnee($command->academicYearId); + } + + $found = false; + $targetLabel = ''; + $updatedPeriods = []; + foreach ($existing->periods as $period) { + if ($period->sequence === $command->sequence) { + $found = true; + $targetLabel = $period->label; + $updatedPeriods[] = new AcademicPeriod( + sequence: $period->sequence, + label: $period->label, + startDate: new DateTimeImmutable($command->startDate), + endDate: new DateTimeImmutable($command->endDate), + ); + } else { + $updatedPeriods[] = $period; + } + } + + if (!$found) { + throw PeriodeNonTrouveeException::pourSequence($command->sequence, $command->academicYearId); + } + + if (!$command->confirmImpact) { + $hasGrades = $this->gradeExistenceChecker->hasGradesInPeriod( + $tenantId, + $academicYearId, + $command->sequence, + ); + + if ($hasGrades) { + throw PeriodeAvecNotesException::confirmationRequise($targetLabel); + } + } + + $newConfiguration = new PeriodConfiguration($existing->type, $updatedPeriods); + + $this->repository->save($tenantId, $academicYearId, $newConfiguration); + + $this->eventBus->dispatch(new PeriodeModifiee( + academicYearId: $academicYearId, + tenantId: $tenantId, + periodSequence: $command->sequence, + periodLabel: $targetLabel, + occurredOn: $this->clock->now(), + )); + + return $newConfiguration; + } +} diff --git a/backend/src/Administration/Application/Port/GradeExistenceChecker.php b/backend/src/Administration/Application/Port/GradeExistenceChecker.php new file mode 100644 index 0000000..773eb08 --- /dev/null +++ b/backend/src/Administration/Application/Port/GradeExistenceChecker.php @@ -0,0 +1,23 @@ +repository->findByAcademicYear( + TenantId::fromString($query->tenantId), + AcademicYearId::fromString($query->academicYearId), + ); + + if ($config === null) { + return null; + } + + $now = $this->clock->now(); + $currentPeriod = $config->currentPeriod($now); + + $periodDtos = array_map( + static fn ($period) => PeriodDto::fromDomain($period, $now), + $config->periods, + ); + + $currentPeriodDto = $currentPeriod !== null + ? PeriodDto::fromDomain($currentPeriod, $now) + : null; + + return new PeriodsResultDto( + type: $config->type->value, + periods: $periodDtos, + currentPeriod: $currentPeriodDto, + ); + } +} diff --git a/backend/src/Administration/Application/Query/GetPeriods/GetPeriodsQuery.php b/backend/src/Administration/Application/Query/GetPeriods/GetPeriodsQuery.php new file mode 100644 index 0000000..77dcddc --- /dev/null +++ b/backend/src/Administration/Application/Query/GetPeriods/GetPeriodsQuery.php @@ -0,0 +1,17 @@ +sequence, + label: $period->label, + startDate: $period->startDate->format('Y-m-d'), + endDate: $period->endDate->format('Y-m-d'), + isCurrent: $period->containsDate($now), + daysRemaining: $period->daysRemaining($now), + isPast: $period->isPast($now), + ); + } +} diff --git a/backend/src/Administration/Application/Query/GetPeriods/PeriodsResultDto.php b/backend/src/Administration/Application/Query/GetPeriods/PeriodsResultDto.php new file mode 100644 index 0000000..198afc2 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetPeriods/PeriodsResultDto.php @@ -0,0 +1,21 @@ +gradeExistenceChecker->hasGradesInPeriod( + TenantId::fromString($query->tenantId), + AcademicYearId::fromString($query->academicYearId), + $query->periodSequence, + ); + } +} diff --git a/backend/src/Administration/Application/Query/HasGradesInPeriod/HasGradesInPeriodQuery.php b/backend/src/Administration/Application/Query/HasGradesInPeriod/HasGradesInPeriodQuery.php new file mode 100644 index 0000000..ec1ac53 --- /dev/null +++ b/backend/src/Administration/Application/Query/HasGradesInPeriod/HasGradesInPeriodQuery.php @@ -0,0 +1,21 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->academicYearId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/PeriodesConfigurees.php b/backend/src/Administration/Domain/Event/PeriodesConfigurees.php new file mode 100644 index 0000000..14911e1 --- /dev/null +++ b/backend/src/Administration/Domain/Event/PeriodesConfigurees.php @@ -0,0 +1,40 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->academicYearId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/InvalidPeriodCountException.php b/backend/src/Administration/Domain/Exception/InvalidPeriodCountException.php new file mode 100644 index 0000000..c1ea362 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/InvalidPeriodCountException.php @@ -0,0 +1,22 @@ +endDate <= $this->startDate) { + throw InvalidPeriodDatesException::endBeforeStart( + $this->label, + $this->startDate->format('Y-m-d'), + $this->endDate->format('Y-m-d'), + ); + } + } + + public function containsDate(DateTimeImmutable $date): bool + { + $d = $date->format('Y-m-d'); + + return $d >= $this->startDate->format('Y-m-d') + && $d <= $this->endDate->format('Y-m-d'); + } + + public function daysRemaining(DateTimeImmutable $now): int + { + $today = $now->format('Y-m-d'); + + if ($today > $this->endDate->format('Y-m-d')) { + return 0; + } + + if ($today < $this->startDate->format('Y-m-d')) { + return (int) $this->startDate->diff($this->endDate)->days; + } + + return (int) $now->setTime(0, 0)->diff($this->endDate->setTime(0, 0))->days; + } + + public function isPast(DateTimeImmutable $now): bool + { + return $now->format('Y-m-d') > $this->endDate->format('Y-m-d'); + } +} diff --git a/backend/src/Administration/Domain/Model/AcademicYear/DefaultPeriods.php b/backend/src/Administration/Domain/Model/AcademicYear/DefaultPeriods.php new file mode 100644 index 0000000..731ccb2 --- /dev/null +++ b/backend/src/Administration/Domain/Model/AcademicYear/DefaultPeriods.php @@ -0,0 +1,63 @@ +modify('-1 day'); + $firstDayAfterFeb = $lastDayOfFeb->modify('+1 day'); + + return match ($type) { + PeriodType::TRIMESTER => new PeriodConfiguration($type, [ + new AcademicPeriod( + sequence: 1, + label: 'T1', + startDate: new DateTimeImmutable("{$startYear}-09-01"), + endDate: new DateTimeImmutable("{$startYear}-11-30"), + ), + new AcademicPeriod( + sequence: 2, + label: 'T2', + startDate: new DateTimeImmutable("{$startYear}-12-01"), + endDate: $lastDayOfFeb, + ), + new AcademicPeriod( + sequence: 3, + label: 'T3', + startDate: $firstDayAfterFeb, + endDate: new DateTimeImmutable("{$endYear}-06-30"), + ), + ]), + PeriodType::SEMESTER => new PeriodConfiguration($type, [ + new AcademicPeriod( + sequence: 1, + label: 'S1', + startDate: new DateTimeImmutable("{$startYear}-09-01"), + endDate: new DateTimeImmutable("{$endYear}-01-31"), + ), + new AcademicPeriod( + sequence: 2, + label: 'S2', + startDate: new DateTimeImmutable("{$endYear}-02-01"), + endDate: new DateTimeImmutable("{$endYear}-06-30"), + ), + ]), + }; + } +} diff --git a/backend/src/Administration/Domain/Model/AcademicYear/PeriodConfiguration.php b/backend/src/Administration/Domain/Model/AcademicYear/PeriodConfiguration.php new file mode 100644 index 0000000..bb5be71 --- /dev/null +++ b/backend/src/Administration/Domain/Model/AcademicYear/PeriodConfiguration.php @@ -0,0 +1,120 @@ +type->expectedCount()) { + throw InvalidPeriodCountException::forType( + $this->type->value, + $this->type->expectedCount(), + count($periods), + ); + } + + $sorted = $periods; + usort($sorted, static fn (AcademicPeriod $a, AcademicPeriod $b): int => $a->startDate <=> $b->startDate); + + self::validateNoOverlap($sorted); + self::validateContiguity($sorted); + + $this->periods = $sorted; + } + + public function currentPeriod(DateTimeImmutable $now): ?AcademicPeriod + { + foreach ($this->periods as $period) { + if ($period->containsDate($now)) { + return $period; + } + } + + return null; + } + + public function periodBySequence(int $sequence): ?AcademicPeriod + { + foreach ($this->periods as $period) { + if ($period->sequence === $sequence) { + return $period; + } + } + + return null; + } + + public function startDate(): DateTimeImmutable + { + return $this->periods[0]->startDate; + } + + public function endDate(): DateTimeImmutable + { + return $this->periods[count($this->periods) - 1]->endDate; + } + + /** + * @param AcademicPeriod[] $periods Sorted by startDate + */ + private static function validateNoOverlap(array $periods): void + { + for ($i = 1; $i < count($periods); ++$i) { + if ($periods[$i]->startDate <= $periods[$i - 1]->endDate) { + throw PeriodsOverlapException::between( + $periods[$i - 1]->label, + $periods[$i]->label, + ); + } + } + } + + /** + * @param AcademicPeriod[] $periods Sorted by startDate + */ + private static function validateContiguity(array $periods): void + { + for ($i = 1; $i < count($periods); ++$i) { + $previousEnd = $periods[$i - 1]->endDate; + $nextStart = $periods[$i]->startDate; + + $dayAfterPreviousEnd = $previousEnd->modify('+1 day'); + + if ($dayAfterPreviousEnd->format('Y-m-d') !== $nextStart->format('Y-m-d')) { + throw PeriodsCoverageGapException::gapBetween( + $periods[$i - 1]->label, + $periods[$i]->label, + ); + } + } + } +} diff --git a/backend/src/Administration/Domain/Model/AcademicYear/PeriodType.php b/backend/src/Administration/Domain/Model/AcademicYear/PeriodType.php new file mode 100644 index 0000000..bee585f --- /dev/null +++ b/backend/src/Administration/Domain/Model/AcademicYear/PeriodType.php @@ -0,0 +1,25 @@ + 3, + self::SEMESTER => 2, + }; + } +} diff --git a/backend/src/Administration/Domain/Model/Subject/Subject.php b/backend/src/Administration/Domain/Model/Subject/Subject.php index ba2be35..be2866b 100644 --- a/backend/src/Administration/Domain/Model/Subject/Subject.php +++ b/backend/src/Administration/Domain/Model/Subject/Subject.php @@ -88,8 +88,9 @@ final class Subject extends AggregateRoot $this->recordEvent(new MatiereModifiee( subjectId: $this->id, tenantId: $this->tenantId, - ancienNom: $ancienNom, - nouveauNom: $nouveauNom, + champ: 'nom', + ancienneValeur: (string) $ancienNom, + nouvelleValeur: (string) $nouveauNom, occurredOn: $at, )); } @@ -106,8 +107,18 @@ final class Subject extends AggregateRoot return; } + $ancienCode = $this->code; $this->code = $nouveauCode; $this->updatedAt = $at; + + $this->recordEvent(new MatiereModifiee( + subjectId: $this->id, + tenantId: $this->tenantId, + champ: 'code', + ancienneValeur: (string) $ancienCode, + nouvelleValeur: (string) $nouveauCode, + occurredOn: $at, + )); } /** @@ -123,8 +134,18 @@ final class Subject extends AggregateRoot return; } + $ancienneCouleur = $this->color; $this->color = $couleur; $this->updatedAt = $at; + + $this->recordEvent(new MatiereModifiee( + subjectId: $this->id, + tenantId: $this->tenantId, + champ: 'couleur', + ancienneValeur: $ancienneCouleur !== null ? (string) $ancienneCouleur : null, + nouvelleValeur: $couleur !== null ? (string) $couleur : null, + occurredOn: $at, + )); } /** @@ -136,8 +157,18 @@ final class Subject extends AggregateRoot return; } + $ancienneDescription = $this->description; $this->description = $description; $this->updatedAt = $at; + + $this->recordEvent(new MatiereModifiee( + subjectId: $this->id, + tenantId: $this->tenantId, + champ: 'description', + ancienneValeur: $ancienneDescription, + nouvelleValeur: $description, + occurredOn: $at, + )); } /** diff --git a/backend/src/Administration/Domain/Repository/PeriodConfigurationRepository.php b/backend/src/Administration/Domain/Repository/PeriodConfigurationRepository.php new file mode 100644 index 0000000..48cc641 --- /dev/null +++ b/backend/src/Administration/Domain/Repository/PeriodConfigurationRepository.php @@ -0,0 +1,16 @@ + + */ +final readonly class ConfigurePeriodsProcessor implements ProcessorInterface +{ + public function __construct( + private ConfigurePeriodsHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + private Clock $clock, + ) { + } + + /** + * @param PeriodResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): PeriodResource + { + if (!$this->authorizationChecker->isGranted(PeriodVoter::CONFIGURE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer les périodes.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + /** @var string $rawAcademicYearId */ + $rawAcademicYearId = $uriVariables['academicYearId']; + + $academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId); + if ($academicYearId === null) { + throw new NotFoundHttpException('Année scolaire non trouvée.'); + } + + try { + $startYear = $data->startYear + ?? $this->academicYearResolver->resolveStartYear($rawAcademicYearId) + ?? (int) $this->clock->now()->format('Y'); + + $command = new ConfigurePeriodsCommand( + tenantId: $tenantId, + academicYearId: $academicYearId, + periodType: $data->periodType ?? 'trimester', + startYear: $startYear, + ); + + $config = ($this->handler)($command); + + $resource = new PeriodResource(); + $resource->academicYearId = $academicYearId; + $resource->type = $config->type->value; + $resource->periods = []; + + foreach ($config->periods as $period) { + $item = new PeriodItem(); + $item->sequence = $period->sequence; + $item->label = $period->label; + $item->startDate = $period->startDate->format('Y-m-d'); + $item->endDate = $period->endDate->format('Y-m-d'); + $resource->periods[] = $item; + } + + return $resource; + } catch (PeriodesDejaConfigureesException $e) { + throw new ConflictHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/CreateSubjectProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/CreateSubjectProcessor.php index 9c98640..cfec40a 100644 --- a/backend/src/Administration/Infrastructure/Api/Processor/CreateSubjectProcessor.php +++ b/backend/src/Administration/Infrastructure/Api/Processor/CreateSubjectProcessor.php @@ -73,18 +73,7 @@ final readonly class CreateSubjectProcessor implements ProcessorInterface $this->eventBus->dispatch($event); } - // Return the created resource - $resource = new SubjectResource(); - $resource->id = (string) $subject->id; - $resource->name = (string) $subject->name; - $resource->code = (string) $subject->code; - $resource->color = $subject->color !== null ? (string) $subject->color : null; - $resource->description = $subject->description; - $resource->status = $subject->status->value; - $resource->createdAt = $subject->createdAt; - $resource->updatedAt = $subject->updatedAt; - - return $resource; + return SubjectResource::fromDomain($subject); } catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) { throw new BadRequestHttpException($e->getMessage()); } catch (SubjectDejaExistanteException $e) { diff --git a/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php new file mode 100644 index 0000000..f44135f --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php @@ -0,0 +1,113 @@ + + */ +final readonly class UpdatePeriodProcessor implements ProcessorInterface +{ + public function __construct( + private UpdatePeriodHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + ) { + } + + /** + * @param PeriodResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): PeriodResource + { + if (!$this->authorizationChecker->isGranted(PeriodVoter::CONFIGURE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier les périodes.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + /** @var string $rawAcademicYearId */ + $rawAcademicYearId = $uriVariables['academicYearId']; + + $academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId); + if ($academicYearId === null) { + throw new NotFoundHttpException('Année scolaire non trouvée.'); + } + + /** @var int|string $sequence */ + $sequence = $uriVariables['sequence']; + + $startDate = $data->startDate; + $endDate = $data->endDate; + + if ($startDate === null || $startDate === '' || $endDate === null || $endDate === '') { + throw new BadRequestHttpException('Les dates de début et de fin sont obligatoires.'); + } + + try { + $command = new UpdatePeriodCommand( + tenantId: $tenantId, + academicYearId: $academicYearId, + sequence: (int) $sequence, + startDate: $startDate, + endDate: $endDate, + confirmImpact: $data->confirmImpact ?? false, + ); + + $config = ($this->handler)($command); + + $resource = new PeriodResource(); + $resource->academicYearId = $academicYearId; + $resource->type = $config->type->value; + $resource->periods = []; + + foreach ($config->periods as $period) { + $item = new PeriodItem(); + $item->sequence = $period->sequence; + $item->label = $period->label; + $item->startDate = $period->startDate->format('Y-m-d'); + $item->endDate = $period->endDate->format('Y-m-d'); + $resource->periods[] = $item; + } + + return $resource; + } catch (PeriodesNonConfigureesException|PeriodeNonTrouveeException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (PeriodeAvecNotesException $e) { + throw new ConflictHttpException($e->getMessage()); + } catch (InvalidPeriodDatesException|PeriodsOverlapException|PeriodsCoverageGapException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/UpdateSubjectProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/UpdateSubjectProcessor.php index 93663d4..79c3ed4 100644 --- a/backend/src/Administration/Infrastructure/Api/Processor/UpdateSubjectProcessor.php +++ b/backend/src/Administration/Infrastructure/Api/Processor/UpdateSubjectProcessor.php @@ -86,18 +86,7 @@ final readonly class UpdateSubjectProcessor implements ProcessorInterface $this->eventBus->dispatch($event); } - // Return the updated resource - $resource = new SubjectResource(); - $resource->id = (string) $subject->id; - $resource->name = (string) $subject->name; - $resource->code = (string) $subject->code; - $resource->color = $subject->color !== null ? (string) $subject->color : null; - $resource->description = $subject->description; - $resource->status = $subject->status->value; - $resource->createdAt = $subject->createdAt; - $resource->updatedAt = $subject->updatedAt; - - return $resource; + return SubjectResource::fromDomain($subject); } catch (SubjectNotFoundException|InvalidUuidStringException) { throw new NotFoundHttpException('Matière non trouvée.'); } catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) { diff --git a/backend/src/Administration/Infrastructure/Api/Provider/PeriodsProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/PeriodsProvider.php new file mode 100644 index 0000000..0f13cad --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/PeriodsProvider.php @@ -0,0 +1,96 @@ + + */ +final readonly class PeriodsProvider implements ProviderInterface +{ + public function __construct( + private GetPeriodsHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?PeriodResource + { + if (!$this->authorizationChecker->isGranted(PeriodVoter::VIEW)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les périodes.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + /** @var string $rawAcademicYearId */ + $rawAcademicYearId = $uriVariables['academicYearId']; + + $academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId); + if ($academicYearId === null) { + throw new NotFoundHttpException('Année scolaire non trouvée.'); + } + + $result = ($this->handler)(new GetPeriodsQuery( + tenantId: $tenantId, + academicYearId: $academicYearId, + )); + + if ($result === null) { + return null; + } + + $resource = new PeriodResource(); + $resource->academicYearId = $academicYearId; + $resource->type = $result->type; + $resource->periods = []; + + foreach ($result->periods as $periodDto) { + $item = new PeriodItem(); + $item->sequence = $periodDto->sequence; + $item->label = $periodDto->label; + $item->startDate = $periodDto->startDate; + $item->endDate = $periodDto->endDate; + $item->isCurrent = $periodDto->isCurrent; + $item->daysRemaining = $periodDto->daysRemaining; + $item->isPast = $periodDto->isPast; + $resource->periods[] = $item; + } + + if ($result->currentPeriod !== null) { + $current = new PeriodItem(); + $current->sequence = $result->currentPeriod->sequence; + $current->label = $result->currentPeriod->label; + $current->startDate = $result->currentPeriod->startDate; + $current->endDate = $result->currentPeriod->endDate; + $current->isCurrent = true; + $current->daysRemaining = $result->currentPeriod->daysRemaining; + $resource->currentPeriod = $current; + } + + return $resource; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/SubjectCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/SubjectCollectionProvider.php index 3c655a0..d6e0889 100644 --- a/backend/src/Administration/Infrastructure/Api/Provider/SubjectCollectionProvider.php +++ b/backend/src/Administration/Infrastructure/Api/Provider/SubjectCollectionProvider.php @@ -60,23 +60,6 @@ final readonly class SubjectCollectionProvider implements ProviderInterface $subjectDtos = ($this->handler)($query); - return array_map( - static function ($dto) { - $resource = new SubjectResource(); - $resource->id = $dto->id; - $resource->name = $dto->name; - $resource->code = $dto->code; - $resource->color = $dto->color; - $resource->description = $dto->description; - $resource->status = $dto->status; - $resource->createdAt = $dto->createdAt; - $resource->updatedAt = $dto->updatedAt; - $resource->teacherCount = $dto->teacherCount; - $resource->classCount = $dto->classCount; - - return $resource; - }, - $subjectDtos, - ); + return array_map(SubjectResource::fromDto(...), $subjectDtos); } } diff --git a/backend/src/Administration/Infrastructure/Api/Provider/SubjectItemProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/SubjectItemProvider.php index e248c50..0c400c6 100644 --- a/backend/src/Administration/Infrastructure/Api/Provider/SubjectItemProvider.php +++ b/backend/src/Administration/Infrastructure/Api/Provider/SubjectItemProvider.php @@ -62,16 +62,6 @@ final readonly class SubjectItemProvider implements ProviderInterface throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette matière.'); } - $resource = new SubjectResource(); - $resource->id = (string) $subject->id; - $resource->name = (string) $subject->name; - $resource->code = (string) $subject->code; - $resource->color = $subject->color !== null ? (string) $subject->color : null; - $resource->description = $subject->description; - $resource->status = $subject->status->value; - $resource->createdAt = $subject->createdAt; - $resource->updatedAt = $subject->updatedAt; - - return $resource; + return SubjectResource::fromDomain($subject); } } diff --git a/backend/src/Administration/Infrastructure/Api/Resource/PeriodItem.php b/backend/src/Administration/Infrastructure/Api/Resource/PeriodItem.php new file mode 100644 index 0000000..81e2bde --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/PeriodItem.php @@ -0,0 +1,28 @@ +id = (string) $subject->id; + $resource->name = (string) $subject->name; + $resource->code = (string) $subject->code; + $resource->color = $subject->color !== null ? (string) $subject->color : null; + $resource->description = $subject->description; + $resource->status = $subject->status->value; + $resource->createdAt = $subject->createdAt; + $resource->updatedAt = $subject->updatedAt; + + return $resource; + } + + /** + * Crée un SubjectResource à partir d'un DTO de query. + */ + public static function fromDto(SubjectDto $dto): self + { + $resource = new self(); + $resource->id = $dto->id; + $resource->name = $dto->name; + $resource->code = $dto->code; + $resource->color = $dto->color; + $resource->description = $dto->description; + $resource->status = $dto->status; + $resource->createdAt = $dto->createdAt; + $resource->updatedAt = $dto->updatedAt; + $resource->teacherCount = $dto->teacherCount; + $resource->classCount = $dto->classCount; + + return $resource; + } } diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php index 8badebc..d44042a 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php @@ -28,28 +28,32 @@ final readonly class DoctrineClassRepository implements ClassRepository #[Override] public function save(SchoolClass $class): void { - $data = [ - 'id' => (string) $class->id, - 'tenant_id' => (string) $class->tenantId, - 'school_id' => (string) $class->schoolId, - 'academic_year_id' => (string) $class->academicYearId, - 'name' => (string) $class->name, - 'level' => $class->level?->value, - 'capacity' => $class->capacity, - 'status' => $class->status->value, - 'description' => $class->description, - 'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM), - 'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM), - 'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM), - ]; - - $exists = $this->findById($class->id) !== null; - - if ($exists) { - $this->connection->update('school_classes', $data, ['id' => (string) $class->id]); - } else { - $this->connection->insert('school_classes', $data); - } + $this->connection->executeStatement( + 'INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, description, created_at, updated_at, deleted_at) + VALUES (:id, :tenant_id, :school_id, :academic_year_id, :name, :level, :capacity, :status, :description, :created_at, :updated_at, :deleted_at) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + level = EXCLUDED.level, + capacity = EXCLUDED.capacity, + status = EXCLUDED.status, + description = EXCLUDED.description, + updated_at = EXCLUDED.updated_at, + deleted_at = EXCLUDED.deleted_at', + [ + 'id' => (string) $class->id, + 'tenant_id' => (string) $class->tenantId, + 'school_id' => (string) $class->schoolId, + 'academic_year_id' => (string) $class->academicYearId, + 'name' => (string) $class->name, + 'level' => $class->level?->value, + 'capacity' => $class->capacity, + 'status' => $class->status->value, + 'description' => $class->description, + 'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM), + 'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM), + ], + ); } #[Override] diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrinePeriodConfigurationRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrinePeriodConfigurationRepository.php new file mode 100644 index 0000000..d8f68bb --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrinePeriodConfigurationRepository.php @@ -0,0 +1,119 @@ +connection->transactional(function () use ($tenantId, $academicYearId, $configuration): void { + $tenantIdStr = (string) $tenantId; + $academicYearIdStr = (string) $academicYearId; + $now = (new DateTimeImmutable())->format(DateTimeImmutable::ATOM); + + $sequences = []; + foreach ($configuration->periods as $period) { + $sequences[] = $period->sequence; + + $this->connection->executeStatement( + 'INSERT INTO academic_periods (tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date, created_at, updated_at) + VALUES (:tenant_id, :academic_year_id, :period_type, :sequence, :label, :start_date, :end_date, :created_at, :updated_at) + ON CONFLICT (tenant_id, academic_year_id, sequence) DO UPDATE SET + period_type = EXCLUDED.period_type, + label = EXCLUDED.label, + start_date = EXCLUDED.start_date, + end_date = EXCLUDED.end_date, + updated_at = EXCLUDED.updated_at', + [ + 'tenant_id' => $tenantIdStr, + 'academic_year_id' => $academicYearIdStr, + 'period_type' => $configuration->type->value, + 'sequence' => $period->sequence, + 'label' => $period->label, + 'start_date' => $period->startDate->format('Y-m-d'), + 'end_date' => $period->endDate->format('Y-m-d'), + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + // Remove any extra periods (e.g. switching from trimester to semester would leave stale rows) + $this->connection->executeStatement( + 'DELETE FROM academic_periods + WHERE tenant_id = :tenant_id + AND academic_year_id = :academic_year_id + AND sequence > :max_sequence', + [ + 'tenant_id' => $tenantIdStr, + 'academic_year_id' => $academicYearIdStr, + 'max_sequence' => count($configuration->periods), + ], + ); + }); + } + + #[Override] + public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM academic_periods + WHERE tenant_id = :tenant_id + AND academic_year_id = :academic_year_id + ORDER BY sequence ASC', + [ + 'tenant_id' => (string) $tenantId, + 'academic_year_id' => (string) $academicYearId, + ], + ); + + if (count($rows) === 0) { + return null; + } + + /** @var string $periodType */ + $periodType = $rows[0]['period_type']; + + $periods = array_map(static function (array $row): AcademicPeriod { + /** @var int|string $sequence */ + $sequence = $row['sequence']; + /** @var string $label */ + $label = $row['label']; + /** @var string $startDate */ + $startDate = $row['start_date']; + /** @var string $endDate */ + $endDate = $row['end_date']; + + return new AcademicPeriod( + sequence: (int) $sequence, + label: $label, + startDate: new DateTimeImmutable($startDate), + endDate: new DateTimeImmutable($endDate), + ); + }, $rows); + + return new PeriodConfiguration(PeriodType::from($periodType), $periods); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSubjectRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSubjectRepository.php index 3c0993f..ea91ae4 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSubjectRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSubjectRepository.php @@ -28,27 +28,31 @@ final readonly class DoctrineSubjectRepository implements SubjectRepository #[Override] public function save(Subject $subject): void { - $data = [ - 'id' => (string) $subject->id, - 'tenant_id' => (string) $subject->tenantId, - 'school_id' => (string) $subject->schoolId, - 'name' => (string) $subject->name, - 'code' => (string) $subject->code, - 'color' => $subject->color !== null ? (string) $subject->color : null, - 'status' => $subject->status->value, - 'description' => $subject->description, - 'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM), - 'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM), - 'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM), - ]; - - $exists = $this->findById($subject->id) !== null; - - if ($exists) { - $this->connection->update('subjects', $data, ['id' => (string) $subject->id]); - } else { - $this->connection->insert('subjects', $data); - } + $this->connection->executeStatement( + 'INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, description, created_at, updated_at, deleted_at) + VALUES (:id, :tenant_id, :school_id, :name, :code, :color, :status, :description, :created_at, :updated_at, :deleted_at) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + code = EXCLUDED.code, + color = EXCLUDED.color, + status = EXCLUDED.status, + description = EXCLUDED.description, + updated_at = EXCLUDED.updated_at, + deleted_at = EXCLUDED.deleted_at', + [ + 'id' => (string) $subject->id, + 'tenant_id' => (string) $subject->tenantId, + 'school_id' => (string) $subject->schoolId, + 'name' => (string) $subject->name, + 'code' => (string) $subject->code, + 'color' => $subject->color !== null ? (string) $subject->color : null, + 'status' => $subject->status->value, + 'description' => $subject->description, + 'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM), + 'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM), + ], + ); } #[Override] diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryPeriodConfigurationRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryPeriodConfigurationRepository.php new file mode 100644 index 0000000..5a386d3 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryPeriodConfigurationRepository.php @@ -0,0 +1,31 @@ + */ + private array $configurations = []; + + public function save(TenantId $tenantId, AcademicYearId $academicYearId, PeriodConfiguration $configuration): void + { + $this->configurations[$this->key($tenantId, $academicYearId)] = $configuration; + } + + public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration + { + return $this->configurations[$this->key($tenantId, $academicYearId)] ?? null; + } + + private function key(TenantId $tenantId, AcademicYearId $academicYearId): string + { + return (string) $tenantId . ':' . (string) $academicYearId; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySubjectRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySubjectRepository.php index 9faac1d..58afafb 100644 --- a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySubjectRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySubjectRepository.php @@ -72,8 +72,8 @@ final class InMemorySubjectRepository implements SubjectRepository ): ?Subject { $subject = $this->byTenantSchoolCode[$this->codeKey($code, $tenantId, $schoolId)] ?? null; - // Filtrer les matières archivées (comme Doctrine avec deleted_at IS NULL) - if ($subject !== null && $subject->deletedAt !== null) { + // Filtrer les matières archivées (cohérent avec findActiveByTenantAndSchool) + if ($subject !== null && $subject->status !== SubjectStatus::ACTIVE) { return null; } diff --git a/backend/src/Administration/Infrastructure/Security/PeriodVoter.php b/backend/src/Administration/Infrastructure/Security/PeriodVoter.php new file mode 100644 index 0000000..19ff91f --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/PeriodVoter.php @@ -0,0 +1,100 @@ + + */ +final class PeriodVoter extends Voter +{ + public const string VIEW = 'PERIOD_VIEW'; + public const string CONFIGURE = 'PERIOD_CONFIGURE'; + + private const array SUPPORTED_ATTRIBUTES = [ + self::VIEW, + self::CONFIGURE, + ]; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true); + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof UserInterface) { + return false; + } + + $roles = $user->getRoles(); + + return match ($attribute) { + self::VIEW => $this->canView($roles), + self::CONFIGURE => $this->canConfigure($roles), + default => false, + }; + } + + /** + * @param string[] $roles + */ + private function canView(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + Role::PROF->value, + Role::VIE_SCOLAIRE->value, + Role::SECRETARIAT->value, + ]); + } + + /** + * @param string[] $roles + */ + private function canConfigure(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + ]); + } + + /** + * @param string[] $userRoles + * @param string[] $allowedRoles + */ + private function hasAnyRole(array $userRoles, array $allowedRoles): bool + { + foreach ($userRoles as $role) { + if (in_array($role, $allowedRoles, true)) { + return true; + } + } + + return false; + } +} diff --git a/backend/src/Administration/Infrastructure/Service/CurrentAcademicYearResolver.php b/backend/src/Administration/Infrastructure/Service/CurrentAcademicYearResolver.php new file mode 100644 index 0000000..3272cb6 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Service/CurrentAcademicYearResolver.php @@ -0,0 +1,73 @@ +resolveStartYear($academicYearId); + if ($startYear === null) { + return null; + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + $name = $tenantId . ':' . $startYear . '-' . ($startYear + 1); + + return Uuid::uuid5(self::NAMESPACE, $name)->toString(); + } + + /** + * Résout l'année de début scolaire pour un identifiant spécial. + * + * @return int|null L'année de début (ex: 2025 pour 2025-2026), ou null si invalide + */ + public function resolveStartYear(string $academicYearId): ?int + { + $offset = match ($academicYearId) { + 'previous' => -1, + 'current' => 0, + 'next' => 1, + default => null, + }; + + if ($offset === null) { + return null; + } + + $now = $this->clock->now(); + $month = (int) $now->format('n'); + $year = (int) $now->format('Y'); + + return ($month >= 9 ? $year : $year - 1) + $offset; + } +} diff --git a/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php b/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php new file mode 100644 index 0000000..c32563d --- /dev/null +++ b/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php @@ -0,0 +1,27 @@ +request('GET', '/api/academic-years/current/periods', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function configurePeriodsReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('PUT', '/api/academic-years/current/periods', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['periodType' => 'trimester', 'startYear' => 2025], + ]); + + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function updatePeriodReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('PATCH', '/api/academic-years/current/periods/1', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/merge-patch+json', + ], + 'json' => ['startDate' => '2025-09-02', 'endDate' => '2025-11-30'], + ]); + + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // Security - Without authentication (with tenant) + // ========================================================================= + + #[Test] + public function getPeriodsReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/periods', [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function configurePeriodsReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('PUT', 'http://ecole-alpha.classeo.local/api/academic-years/current/periods', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['periodType' => 'trimester', 'startYear' => 2025], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function updatePeriodReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('PATCH', 'http://ecole-alpha.classeo.local/api/academic-years/current/periods/1', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/merge-patch+json', + ], + 'json' => ['startDate' => '2025-09-02', 'endDate' => '2025-11-30'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + // ========================================================================= + // Special identifiers - 'current', 'next', 'previous' + // ========================================================================= + + #[Test] + public function getPeriodsAcceptsCurrentIdentifier(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/periods', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + // 401 (no auth) not 404 (invalid id) — proves 'current' is accepted + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getPeriodsAcceptsNextIdentifier(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/next/periods', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getPeriodsAcceptsPreviousIdentifier(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/previous/periods', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ConfigurePeriods/ConfigurePeriodsHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ConfigurePeriods/ConfigurePeriodsHandlerTest.php new file mode 100644 index 0000000..2349b58 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ConfigurePeriods/ConfigurePeriodsHandlerTest.php @@ -0,0 +1,139 @@ +repository = new InMemoryPeriodConfigurationRepository(); + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-01-31 10:00:00'); + } + }; + $eventBus = new class implements MessageBusInterface { + public function dispatch(object $message, array $stamps = []): Envelope + { + return new Envelope($message); + } + }; + $this->handler = new ConfigurePeriodsHandler($this->repository, $clock, $eventBus); + } + + #[Test] + public function itConfiguresTrimesterPeriods(): void + { + $command = new ConfigurePeriodsCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + periodType: 'trimester', + startYear: 2025, + ); + + $config = ($this->handler)($command); + + self::assertSame(PeriodType::TRIMESTER, $config->type); + self::assertCount(3, $config->periods); + self::assertSame('T1', $config->periods[0]->label); + self::assertSame('T2', $config->periods[1]->label); + self::assertSame('T3', $config->periods[2]->label); + } + + #[Test] + public function itConfiguresSemesterPeriods(): void + { + $command = new ConfigurePeriodsCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + periodType: 'semester', + startYear: 2025, + ); + + $config = ($this->handler)($command); + + self::assertSame(PeriodType::SEMESTER, $config->type); + self::assertCount(2, $config->periods); + self::assertSame('S1', $config->periods[0]->label); + self::assertSame('S2', $config->periods[1]->label); + } + + #[Test] + public function itPersistsConfiguration(): void + { + $command = new ConfigurePeriodsCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + periodType: 'trimester', + startYear: 2025, + ); + + ($this->handler)($command); + + $saved = $this->repository->findByAcademicYear( + \App\Shared\Domain\Tenant\TenantId::fromString(self::TENANT_ID), + \App\Administration\Domain\Model\SchoolClass\AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + self::assertNotNull($saved); + self::assertSame(PeriodType::TRIMESTER, $saved->type); + } + + #[Test] + public function itRejectsDoubleConfiguration(): void + { + $command = new ConfigurePeriodsCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + periodType: 'trimester', + startYear: 2025, + ); + + ($this->handler)($command); + + $this->expectException(PeriodesDejaConfigureesException::class); + ($this->handler)($command); + } + + #[Test] + public function itAllowsDifferentTenantsToConfigureSameYear(): void + { + $otherTenantId = '550e8400-e29b-41d4-a716-446655440099'; + + ($this->handler)(new ConfigurePeriodsCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + periodType: 'trimester', + startYear: 2025, + )); + + $config = ($this->handler)(new ConfigurePeriodsCommand( + tenantId: $otherTenantId, + academicYearId: self::ACADEMIC_YEAR_ID, + periodType: 'semester', + startYear: 2025, + )); + + self::assertSame(PeriodType::SEMESTER, $config->type); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php new file mode 100644 index 0000000..02c9532 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php @@ -0,0 +1,184 @@ +repository = new InMemoryPeriodConfigurationRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2025-10-15 10:00:00'); + } + }; + $this->eventBus = new class implements MessageBusInterface { + public function dispatch(object $message, array $stamps = []): Envelope + { + return new Envelope($message); + } + }; + $this->handler = new UpdatePeriodHandler($this->repository, new NoOpGradeExistenceChecker(), $this->clock, $this->eventBus); + } + + #[Test] + public function itUpdatesPeriodDates(): void + { + $this->seedTrimesterConfig(); + + $command = new UpdatePeriodCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + sequence: 1, + startDate: '2025-09-01', + endDate: '2025-12-15', + ); + + $this->expectException(PeriodsOverlapException::class); + ($this->handler)($command); + } + + #[Test] + public function itUpdatesValidPeriodDates(): void + { + $this->seedTrimesterConfig(); + + $command = new UpdatePeriodCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + sequence: 1, + startDate: '2025-09-02', + endDate: '2025-11-30', + ); + + $config = ($this->handler)($command); + + self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d')); + self::assertSame('2025-11-30', $config->periods[0]->endDate->format('Y-m-d')); + } + + #[Test] + public function itRejectsWhenNoConfigurationExists(): void + { + $this->expectException(PeriodesNonConfigureesException::class); + + ($this->handler)(new UpdatePeriodCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + sequence: 1, + startDate: '2025-09-01', + endDate: '2025-11-30', + )); + } + + #[Test] + public function itRejectsUnknownSequence(): void + { + $this->seedTrimesterConfig(); + + $this->expectException(PeriodeNonTrouveeException::class); + + ($this->handler)(new UpdatePeriodCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + sequence: 99, + startDate: '2025-09-01', + endDate: '2025-11-30', + )); + } + + #[Test] + public function itRejectsUpdateWhenPeriodHasGradesWithoutConfirmation(): void + { + $this->seedTrimesterConfig(); + + $gradeChecker = new class implements GradeExistenceChecker { + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return true; + } + }; + + $handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus); + + $this->expectException(PeriodeAvecNotesException::class); + + $handler(new UpdatePeriodCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + sequence: 1, + startDate: '2025-09-02', + endDate: '2025-11-30', + )); + } + + #[Test] + public function itAllowsUpdateWhenPeriodHasGradesWithConfirmation(): void + { + $this->seedTrimesterConfig(); + + $gradeChecker = new class implements GradeExistenceChecker { + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return true; + } + }; + + $handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus); + + $config = $handler(new UpdatePeriodCommand( + tenantId: self::TENANT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + sequence: 1, + startDate: '2025-09-02', + endDate: '2025-11-30', + confirmImpact: true, + )); + + self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d')); + } + + private function seedTrimesterConfig(): void + { + $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025); + $this->repository->save( + TenantId::fromString(self::TENANT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + $config, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetPeriods/GetPeriodsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetPeriods/GetPeriodsHandlerTest.php new file mode 100644 index 0000000..49bf73f --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetPeriods/GetPeriodsHandlerTest.php @@ -0,0 +1,111 @@ +repository = new InMemoryPeriodConfigurationRepository(); + } + + #[Test] + public function itReturnsNullWhenNoPeriodsConfigured(): void + { + $handler = $this->createHandler('2025-10-15'); + + $result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID)); + + self::assertNull($result); + } + + #[Test] + public function itReturnsPeriodsWithCurrentPeriodInfo(): void + { + $this->seedTrimesterConfig(); + $handler = $this->createHandler('2025-10-15'); + + $result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID)); + + self::assertNotNull($result); + self::assertSame('trimester', $result->type); + self::assertCount(3, $result->periods); + + // T1 is current + self::assertNotNull($result->currentPeriod); + self::assertSame('T1', $result->currentPeriod->label); + self::assertTrue($result->currentPeriod->isCurrent); + self::assertSame(46, $result->currentPeriod->daysRemaining); + } + + #[Test] + public function itReturnsNullCurrentPeriodWhenOutOfRange(): void + { + $this->seedTrimesterConfig(); + $handler = $this->createHandler('2025-08-15'); + + $result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID)); + + self::assertNotNull($result); + self::assertNull($result->currentPeriod); + } + + #[Test] + public function itMarksPastPeriods(): void + { + $this->seedTrimesterConfig(); + $handler = $this->createHandler('2026-04-15'); + + $result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID)); + + self::assertNotNull($result); + self::assertTrue($result->periods[0]->isPast); + self::assertTrue($result->periods[1]->isPast); + self::assertFalse($result->periods[2]->isPast); + } + + private function seedTrimesterConfig(): void + { + $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025); + $this->repository->save( + TenantId::fromString(self::TENANT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + $config, + ); + } + + private function createHandler(string $dateString): GetPeriodsHandler + { + $clock = new class($dateString) implements Clock { + public function __construct(private readonly string $dateString) + { + } + + public function now(): DateTimeImmutable + { + return new DateTimeImmutable($this->dateString); + } + }; + + return new GetPeriodsHandler($this->repository, $clock); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/AcademicYear/AcademicPeriodTest.php b/backend/tests/Unit/Administration/Domain/Model/AcademicYear/AcademicPeriodTest.php new file mode 100644 index 0000000..e88f120 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/AcademicYear/AcademicPeriodTest.php @@ -0,0 +1,127 @@ +sequence); + self::assertSame('T1', $period->label); + self::assertSame('2025-09-01', $period->startDate->format('Y-m-d')); + self::assertSame('2025-11-30', $period->endDate->format('Y-m-d')); + } + + #[Test] + public function itRejectsEndDateBeforeStartDate(): void + { + $this->expectException(InvalidPeriodDatesException::class); + + new AcademicPeriod( + sequence: 1, + label: 'T1', + startDate: new DateTimeImmutable('2025-11-30'), + endDate: new DateTimeImmutable('2025-09-01'), + ); + } + + #[Test] + public function itRejectsEqualDates(): void + { + $this->expectException(InvalidPeriodDatesException::class); + + new AcademicPeriod( + sequence: 1, + label: 'T1', + startDate: new DateTimeImmutable('2025-09-01'), + endDate: new DateTimeImmutable('2025-09-01'), + ); + } + + #[Test] + public function itDetectsDateWithinPeriod(): void + { + $period = new AcademicPeriod( + sequence: 1, + label: 'T1', + startDate: new DateTimeImmutable('2025-09-01'), + endDate: new DateTimeImmutable('2025-11-30'), + ); + + self::assertTrue($period->containsDate(new DateTimeImmutable('2025-10-15'))); + self::assertTrue($period->containsDate(new DateTimeImmutable('2025-09-01'))); + self::assertTrue($period->containsDate(new DateTimeImmutable('2025-11-30'))); + self::assertFalse($period->containsDate(new DateTimeImmutable('2025-08-31'))); + self::assertFalse($period->containsDate(new DateTimeImmutable('2025-12-01'))); + } + + #[Test] + public function itIncludesLastDayRegardlessOfTime(): void + { + $period = new AcademicPeriod( + sequence: 1, + label: 'T1', + startDate: new DateTimeImmutable('2025-09-01'), + endDate: new DateTimeImmutable('2025-11-30'), + ); + + // Last day at 15:00 must still be "within" the period + self::assertTrue($period->containsDate(new DateTimeImmutable('2025-11-30 15:00:00'))); + self::assertFalse($period->isPast(new DateTimeImmutable('2025-11-30 23:59:59'))); + self::assertTrue($period->isPast(new DateTimeImmutable('2025-12-01 00:00:01'))); + + // daysRemaining on last day should be 0 (same day) + self::assertSame(0, $period->daysRemaining(new DateTimeImmutable('2025-11-30 15:00:00'))); + } + + #[Test] + public function itCalculatesDaysRemaining(): void + { + $period = new AcademicPeriod( + sequence: 1, + label: 'T1', + startDate: new DateTimeImmutable('2025-09-01'), + endDate: new DateTimeImmutable('2025-11-30'), + ); + + // During the period + self::assertSame(30, $period->daysRemaining(new DateTimeImmutable('2025-10-31'))); + + // After the period + self::assertSame(0, $period->daysRemaining(new DateTimeImmutable('2025-12-01'))); + + // Before the period: returns total period length + self::assertSame(90, $period->daysRemaining(new DateTimeImmutable('2025-08-01'))); + } + + #[Test] + public function itDetectsPastPeriod(): void + { + $period = new AcademicPeriod( + sequence: 1, + label: 'T1', + startDate: new DateTimeImmutable('2025-09-01'), + endDate: new DateTimeImmutable('2025-11-30'), + ); + + self::assertTrue($period->isPast(new DateTimeImmutable('2025-12-01'))); + self::assertFalse($period->isPast(new DateTimeImmutable('2025-11-30'))); + self::assertFalse($period->isPast(new DateTimeImmutable('2025-10-15'))); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/AcademicYear/DefaultPeriodsTest.php b/backend/tests/Unit/Administration/Domain/Model/AcademicYear/DefaultPeriodsTest.php new file mode 100644 index 0000000..ab06c95 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/AcademicYear/DefaultPeriodsTest.php @@ -0,0 +1,71 @@ +type); + self::assertCount(3, $config->periods); + + self::assertSame('T1', $config->periods[0]->label); + self::assertSame('2025-09-01', $config->periods[0]->startDate->format('Y-m-d')); + self::assertSame('2025-11-30', $config->periods[0]->endDate->format('Y-m-d')); + + self::assertSame('T2', $config->periods[1]->label); + self::assertSame('2025-12-01', $config->periods[1]->startDate->format('Y-m-d')); + self::assertSame('2026-02-28', $config->periods[1]->endDate->format('Y-m-d')); + + self::assertSame('T3', $config->periods[2]->label); + self::assertSame('2026-03-01', $config->periods[2]->startDate->format('Y-m-d')); + self::assertSame('2026-06-30', $config->periods[2]->endDate->format('Y-m-d')); + } + + #[Test] + public function itHandlesLeapYearForTrimesters(): void + { + // 2023-2024 : 2024 is a leap year, Feb has 29 days + $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2023); + + self::assertSame('2024-02-29', $config->periods[1]->endDate->format('Y-m-d')); + self::assertSame('2024-03-01', $config->periods[2]->startDate->format('Y-m-d')); + } + + #[Test] + public function itHandlesNonLeapYearForTrimesters(): void + { + // 2024-2025 : 2025 is not a leap year + $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2024); + + self::assertSame('2025-02-28', $config->periods[1]->endDate->format('Y-m-d')); + self::assertSame('2025-03-01', $config->periods[2]->startDate->format('Y-m-d')); + } + + #[Test] + public function itGeneratesDefaultSemesters(): void + { + $config = DefaultPeriods::forType(PeriodType::SEMESTER, 2025); + + self::assertSame(PeriodType::SEMESTER, $config->type); + self::assertCount(2, $config->periods); + + self::assertSame('S1', $config->periods[0]->label); + self::assertSame('2025-09-01', $config->periods[0]->startDate->format('Y-m-d')); + self::assertSame('2026-01-31', $config->periods[0]->endDate->format('Y-m-d')); + + self::assertSame('S2', $config->periods[1]->label); + self::assertSame('2026-02-01', $config->periods[1]->startDate->format('Y-m-d')); + self::assertSame('2026-06-30', $config->periods[1]->endDate->format('Y-m-d')); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/AcademicYear/PeriodConfigurationTest.php b/backend/tests/Unit/Administration/Domain/Model/AcademicYear/PeriodConfigurationTest.php new file mode 100644 index 0000000..9852c89 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/AcademicYear/PeriodConfigurationTest.php @@ -0,0 +1,150 @@ +validTrimesterConfig(); + + self::assertSame(PeriodType::TRIMESTER, $config->type); + self::assertCount(3, $config->periods); + self::assertSame('2025-09-01', $config->startDate()->format('Y-m-d')); + self::assertSame('2026-06-30', $config->endDate()->format('Y-m-d')); + } + + #[Test] + public function itCreatesValidSemesterConfiguration(): void + { + $config = new PeriodConfiguration(PeriodType::SEMESTER, [ + new AcademicPeriod(1, 'S1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2026-01-31')), + new AcademicPeriod(2, 'S2', new DateTimeImmutable('2026-02-01'), new DateTimeImmutable('2026-06-30')), + ]); + + self::assertSame(PeriodType::SEMESTER, $config->type); + self::assertCount(2, $config->periods); + } + + #[Test] + public function itRejectsWrongPeriodCountForTrimester(): void + { + $this->expectException(InvalidPeriodCountException::class); + + new PeriodConfiguration(PeriodType::TRIMESTER, [ + new AcademicPeriod(1, 'S1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2026-01-31')), + new AcademicPeriod(2, 'S2', new DateTimeImmutable('2026-02-01'), new DateTimeImmutable('2026-06-30')), + ]); + } + + #[Test] + public function itRejectsWrongPeriodCountForSemester(): void + { + $this->expectException(InvalidPeriodCountException::class); + + new PeriodConfiguration(PeriodType::SEMESTER, [ + new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-30')), + new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')), + new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')), + ]); + } + + #[Test] + public function itRejectsOverlappingPeriods(): void + { + $this->expectException(PeriodsOverlapException::class); + + new PeriodConfiguration(PeriodType::TRIMESTER, [ + new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-12-01')), + new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-11-30'), new DateTimeImmutable('2026-02-28')), + new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')), + ]); + } + + #[Test] + public function itRejectsCoverageGap(): void + { + $this->expectException(PeriodsCoverageGapException::class); + + new PeriodConfiguration(PeriodType::TRIMESTER, [ + new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-28')), + new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')), + new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')), + ]); + } + + #[Test] + public function itSortsPeriodsByStartDate(): void + { + $config = new PeriodConfiguration(PeriodType::TRIMESTER, [ + new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')), + new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-30')), + new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')), + ]); + + self::assertSame('T1', $config->periods[0]->label); + self::assertSame('T2', $config->periods[1]->label); + self::assertSame('T3', $config->periods[2]->label); + } + + #[Test] + public function itFindsCurrentPeriod(): void + { + $config = $this->validTrimesterConfig(); + + $current = $config->currentPeriod(new DateTimeImmutable('2025-10-15')); + self::assertNotNull($current); + self::assertSame('T1', $current->label); + + $current = $config->currentPeriod(new DateTimeImmutable('2026-01-15')); + self::assertNotNull($current); + self::assertSame('T2', $current->label); + + $current = $config->currentPeriod(new DateTimeImmutable('2026-05-01')); + self::assertNotNull($current); + self::assertSame('T3', $current->label); + } + + #[Test] + public function itReturnsNullWhenNoCurrentPeriod(): void + { + $config = $this->validTrimesterConfig(); + + self::assertNull($config->currentPeriod(new DateTimeImmutable('2025-08-01'))); + self::assertNull($config->currentPeriod(new DateTimeImmutable('2026-07-01'))); + } + + #[Test] + public function itFindsPeriodBySequence(): void + { + $config = $this->validTrimesterConfig(); + + $period = $config->periodBySequence(2); + self::assertNotNull($period); + self::assertSame('T2', $period->label); + + self::assertNull($config->periodBySequence(4)); + } + + private function validTrimesterConfig(): PeriodConfiguration + { + return new PeriodConfiguration(PeriodType::TRIMESTER, [ + new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-30')), + new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')), + new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')), + ]); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/AcademicYear/PeriodTypeTest.php b/backend/tests/Unit/Administration/Domain/Model/AcademicYear/PeriodTypeTest.php new file mode 100644 index 0000000..6b1ae5f --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/AcademicYear/PeriodTypeTest.php @@ -0,0 +1,31 @@ +expectedCount()); + } + + #[Test] + public function semesterExpectsTwoPeriods(): void + { + self::assertSame(2, PeriodType::SEMESTER->expectedCount()); + } + + #[Test] + public function itHasCorrectValues(): void + { + self::assertSame('trimester', PeriodType::TRIMESTER->value); + self::assertSame('semester', PeriodType::SEMESTER->value); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Subject/SubjectTest.php b/backend/tests/Unit/Administration/Domain/Model/Subject/SubjectTest.php index 01cf33d..c8e9372 100644 --- a/backend/tests/Unit/Administration/Domain/Model/Subject/SubjectTest.php +++ b/backend/tests/Unit/Administration/Domain/Model/Subject/SubjectTest.php @@ -112,8 +112,9 @@ final class SubjectTest extends TestCase $events = $subject->pullDomainEvents(); self::assertCount(1, $events); self::assertInstanceOf(MatiereModifiee::class, $events[0]); - self::assertTrue($events[0]->ancienNom->equals($ancienNom)); - self::assertTrue($events[0]->nouveauNom->equals($nouveauNom)); + self::assertSame('nom', $events[0]->champ); + self::assertSame((string) $ancienNom, $events[0]->ancienneValeur); + self::assertSame((string) $nouveauNom, $events[0]->nouvelleValeur); } #[Test] @@ -130,9 +131,10 @@ final class SubjectTest extends TestCase } #[Test] - public function changerCodeUpdatesCode(): void + public function changerCodeUpdatesCodeAndRecordsEvent(): void { $subject = $this->createSubject(); + $subject->pullDomainEvents(); $at = new DateTimeImmutable('2026-02-01 10:00:00'); $nouveauCode = new SubjectCode('MATHS'); @@ -140,23 +142,33 @@ final class SubjectTest extends TestCase self::assertTrue($subject->code->equals($nouveauCode)); self::assertEquals($at, $subject->updatedAt); + + $events = $subject->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(MatiereModifiee::class, $events[0]); + self::assertSame('code', $events[0]->champ); + self::assertSame('MATH', $events[0]->ancienneValeur); + self::assertSame('MATHS', $events[0]->nouvelleValeur); } #[Test] public function changerCodeWithSameCodeDoesNothing(): void { $subject = $this->createSubject(); + $subject->pullDomainEvents(); $originalUpdatedAt = $subject->updatedAt; $subject->changerCode(new SubjectCode('MATH'), new DateTimeImmutable('2026-02-01 10:00:00')); self::assertEquals($originalUpdatedAt, $subject->updatedAt); + self::assertEmpty($subject->pullDomainEvents()); } #[Test] - public function changerCouleurUpdatesColor(): void + public function changerCouleurUpdatesColorAndRecordsEvent(): void { $subject = $this->createSubject(); + $subject->pullDomainEvents(); $at = new DateTimeImmutable('2026-02-01 10:00:00'); $nouvelleCouleur = new SubjectColor('#EF4444'); @@ -165,41 +177,66 @@ final class SubjectTest extends TestCase self::assertNotNull($subject->color); self::assertTrue($subject->color->equals($nouvelleCouleur)); self::assertEquals($at, $subject->updatedAt); + + $events = $subject->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(MatiereModifiee::class, $events[0]); + self::assertSame('couleur', $events[0]->champ); + self::assertSame('#3B82F6', $events[0]->ancienneValeur); + self::assertSame('#EF4444', $events[0]->nouvelleValeur); } #[Test] - public function changerCouleurToNullRemovesColor(): void + public function changerCouleurToNullRemovesColorAndRecordsEvent(): void { $subject = $this->createSubject(); + $subject->pullDomainEvents(); $at = new DateTimeImmutable('2026-02-01 10:00:00'); $subject->changerCouleur(null, $at); self::assertNull($subject->color); self::assertEquals($at, $subject->updatedAt); + + $events = $subject->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(MatiereModifiee::class, $events[0]); + self::assertSame('couleur', $events[0]->champ); + self::assertSame('#3B82F6', $events[0]->ancienneValeur); + self::assertNull($events[0]->nouvelleValeur); } #[Test] public function changerCouleurWithSameColorDoesNothing(): void { $subject = $this->createSubject(); + $subject->pullDomainEvents(); $originalUpdatedAt = $subject->updatedAt; $subject->changerCouleur(new SubjectColor('#3B82F6'), new DateTimeImmutable('2026-02-01 10:00:00')); self::assertEquals($originalUpdatedAt, $subject->updatedAt); + self::assertEmpty($subject->pullDomainEvents()); } #[Test] - public function decrireUpdatesDescription(): void + public function decrireUpdatesDescriptionAndRecordsEvent(): void { $subject = $this->createSubject(); + $subject->pullDomainEvents(); $at = new DateTimeImmutable('2026-02-01 10:00:00'); $subject->decrire('Cours de mathématiques généralistes', $at); self::assertSame('Cours de mathématiques généralistes', $subject->description); self::assertEquals($at, $subject->updatedAt); + + $events = $subject->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(MatiereModifiee::class, $events[0]); + self::assertSame('description', $events[0]->champ); + self::assertNull($events[0]->ancienneValeur); + self::assertSame('Cours de mathématiques généralistes', $events[0]->nouvelleValeur); } #[Test] diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigurePeriodsProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigurePeriodsProcessorTest.php new file mode 100644 index 0000000..fa4658b --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigurePeriodsProcessorTest.php @@ -0,0 +1,188 @@ +repository = new InMemoryPeriodConfigurationRepository(); + $this->tenantContext = new TenantContext(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2025-10-15 10:00:00'); + } + }; + } + + #[Test] + public function itRejectsUnauthorizedAccess(): void + { + $processor = $this->createProcessor(granted: false); + $this->setTenant(); + + $data = new PeriodResource(); + $data->periodType = 'trimester'; + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsRequestWithoutTenant(): void + { + $processor = $this->createProcessor(granted: true); + + $data = new PeriodResource(); + $data->periodType = 'trimester'; + + $this->expectException(UnauthorizedHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsInvalidAcademicYearId(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new PeriodResource(); + $data->periodType = 'trimester'; + + $this->expectException(NotFoundHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'invalid']); + } + + #[Test] + public function itConfiguresTrimesters(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new PeriodResource(); + $data->periodType = 'trimester'; + $data->startYear = 2025; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'current']); + + self::assertInstanceOf(PeriodResource::class, $result); + self::assertSame('trimester', $result->type); + self::assertCount(3, $result->periods); + self::assertContainsOnlyInstancesOf(PeriodItem::class, $result->periods); + } + + #[Test] + public function itConfiguresSemesters(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new PeriodResource(); + $data->periodType = 'semester'; + $data->startYear = 2025; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'current']); + + self::assertSame('semester', $result->type); + self::assertCount(2, $result->periods); + } + + #[Test] + public function itResolvesCurrentAndReturnsCorrectAcademicYearId(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $expectedUuid = $resolver->resolve('current'); + + $data = new PeriodResource(); + $data->periodType = 'trimester'; + $data->startYear = 2025; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'current']); + + self::assertSame($expectedUuid, $result->academicYearId); + } + + #[Test] + public function itConfiguresNextYear(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new PeriodResource(); + $data->periodType = 'trimester'; + $data->startYear = 2026; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'next']); + + self::assertSame('trimester', $result->type); + self::assertCount(3, $result->periods); + } + + private function setTenant(): void + { + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: TenantId::fromString(self::TENANT_UUID), + subdomain: 'test', + databaseUrl: 'sqlite:///:memory:', + )); + } + + private function createProcessor(bool $granted): ConfigurePeriodsProcessor + { + $authChecker = new class($granted) implements AuthorizationCheckerInterface { + public function __construct(private readonly bool $granted) + { + } + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->granted; + } + }; + + $eventBus = new class implements MessageBusInterface { + public function dispatch(object $message, array $stamps = []): Envelope + { + return new Envelope($message); + } + }; + $handler = new ConfigurePeriodsHandler($this->repository, $this->clock, $eventBus); + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + + return new ConfigurePeriodsProcessor($handler, $this->tenantContext, $authChecker, $resolver, $this->clock); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessorTest.php new file mode 100644 index 0000000..4d5a734 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessorTest.php @@ -0,0 +1,212 @@ +repository = new InMemoryPeriodConfigurationRepository(); + $this->tenantContext = new TenantContext(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2025-10-15 10:00:00'); + } + }; + } + + #[Test] + public function itRejectsInvalidAcademicYearId(): void + { + $processor = $this->createProcessor(); + $this->setTenant(); + + $data = new PeriodResource(); + $data->startDate = '2025-09-02'; + $data->endDate = '2025-11-30'; + + $this->expectException(NotFoundHttpException::class); + $processor->process($data, new Patch(), ['academicYearId' => 'invalid', 'sequence' => 1]); + } + + #[Test] + public function itRejectsWhenNoPeriodsConfigured(): void + { + $processor = $this->createProcessor(); + $this->setTenant(); + + $data = new PeriodResource(); + $data->startDate = '2025-09-02'; + $data->endDate = '2025-11-30'; + + $this->expectException(NotFoundHttpException::class); + $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]); + } + + #[Test] + public function itUpdatesPeriodDates(): void + { + $processor = $this->createProcessor(); + $this->setTenant(); + $this->seedPeriods(); + + $data = new PeriodResource(); + $data->startDate = '2025-09-02'; + $data->endDate = '2025-11-30'; + + $result = $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]); + + self::assertInstanceOf(PeriodResource::class, $result); + self::assertSame('trimester', $result->type); + self::assertCount(3, $result->periods); + self::assertContainsOnlyInstancesOf(PeriodItem::class, $result->periods); + + // First period has updated start date + self::assertSame('2025-09-02', $result->periods[0]->startDate); + self::assertSame('2025-11-30', $result->periods[0]->endDate); + } + + #[Test] + public function itResolvesCurrentAcademicYearId(): void + { + $processor = $this->createProcessor(); + $this->setTenant(); + $this->seedPeriods(); + + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $expectedUuid = $resolver->resolve('current'); + + $data = new PeriodResource(); + $data->startDate = '2025-09-02'; + $data->endDate = '2025-11-30'; + + $result = $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]); + + self::assertSame($expectedUuid, $result->academicYearId); + } + + #[Test] + public function itRejectsMissingStartDate(): void + { + $processor = $this->createProcessor(); + $this->setTenant(); + $this->seedPeriods(); + + $data = new PeriodResource(); + $data->endDate = '2025-11-30'; + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('obligatoires'); + $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]); + } + + #[Test] + public function itRejectsMissingEndDate(): void + { + $processor = $this->createProcessor(); + $this->setTenant(); + $this->seedPeriods(); + + $data = new PeriodResource(); + $data->startDate = '2025-09-01'; + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('obligatoires'); + $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]); + } + + #[Test] + public function itRejectsOverlappingDates(): void + { + $processor = $this->createProcessor(); + $this->setTenant(); + $this->seedPeriods(); + + $data = new PeriodResource(); + // T1 end date overlaps with T2 start date (Dec 1) + $data->startDate = '2025-09-01'; + $data->endDate = '2025-12-15'; + + $this->expectException(BadRequestHttpException::class); + $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]); + } + + private function setTenant(): void + { + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + subdomain: 'test', + databaseUrl: 'sqlite:///:memory:', + )); + } + + private function seedPeriods(): void + { + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $academicYearId = $resolver->resolve('current'); + self::assertNotNull($academicYearId); + + $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025); + $this->repository->save( + TenantId::fromString(self::TENANT_UUID), + AcademicYearId::fromString($academicYearId), + $config, + ); + } + + private function createProcessor(): UpdatePeriodProcessor + { + $authChecker = new class implements AuthorizationCheckerInterface { + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return true; + } + }; + + $eventBus = new class implements MessageBusInterface { + public function dispatch(object $message, array $stamps = []): Envelope + { + return new Envelope($message); + } + }; + $handler = new UpdatePeriodHandler($this->repository, new NoOpGradeExistenceChecker(), $this->clock, $eventBus); + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + + return new UpdatePeriodProcessor($handler, $this->tenantContext, $authChecker, $resolver); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Provider/PeriodsProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/PeriodsProviderTest.php new file mode 100644 index 0000000..3a27c1b --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/PeriodsProviderTest.php @@ -0,0 +1,223 @@ +repository = new InMemoryPeriodConfigurationRepository(); + $this->tenantContext = new TenantContext(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2025-10-15 10:00:00'); + } + }; + } + + #[Test] + public function itRejectsUnauthorizedAccess(): void + { + $provider = $this->createProvider(granted: false); + $this->setTenant(); + + $this->expectException(AccessDeniedHttpException::class); + $provider->provide(new Get(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsRequestWithoutTenant(): void + { + $provider = $this->createProvider(granted: true); + + $this->expectException(UnauthorizedHttpException::class); + $provider->provide(new Get(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsInvalidAcademicYearId(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $this->expectException(NotFoundHttpException::class); + $provider->provide(new Get(), ['academicYearId' => 'invalid']); + } + + #[Test] + public function itReturnsNullWhenNoPeriodsConfigured(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertNull($result); + } + + #[Test] + public function itResolvesCurrentToValidUuidAndReturnsPeriods(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + // Seed periods using the same resolved UUID + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $academicYearId = $resolver->resolve('current'); + self::assertNotNull($academicYearId); + + $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025); + $this->repository->save( + TenantId::fromString(self::TENANT_UUID), + AcademicYearId::fromString($academicYearId), + $config, + ); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertInstanceOf(PeriodResource::class, $result); + self::assertSame($academicYearId, $result->academicYearId); + self::assertSame('trimester', $result->type); + self::assertCount(3, $result->periods); + self::assertContainsOnlyInstancesOf(PeriodItem::class, $result->periods); + } + + #[Test] + public function itResolvesNextAcademicYear(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'next']); + + // No periods for next year → null + self::assertNull($result); + } + + #[Test] + public function itResolvesPreviousAcademicYear(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'previous']); + + // No periods for previous year → null + self::assertNull($result); + } + + #[Test] + public function itReturnsPeriodItemsWithCorrectFields(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $academicYearId = $resolver->resolve('current'); + self::assertNotNull($academicYearId); + + $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025); + $this->repository->save( + TenantId::fromString(self::TENANT_UUID), + AcademicYearId::fromString($academicYearId), + $config, + ); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + self::assertNotNull($result); + + $firstPeriod = $result->periods[0]; + self::assertInstanceOf(PeriodItem::class, $firstPeriod); + self::assertSame(1, $firstPeriod->sequence); + self::assertSame('T1', $firstPeriod->label); + self::assertSame('2025-09-01', $firstPeriod->startDate); + self::assertSame('2025-11-30', $firstPeriod->endDate); + self::assertTrue($firstPeriod->isCurrent); + } + + #[Test] + public function itReturnsCurrentPeriodBanner(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $academicYearId = $resolver->resolve('current'); + self::assertNotNull($academicYearId); + + $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025); + $this->repository->save( + TenantId::fromString(self::TENANT_UUID), + AcademicYearId::fromString($academicYearId), + $config, + ); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + self::assertNotNull($result); + + self::assertInstanceOf(PeriodItem::class, $result->currentPeriod); + self::assertSame('T1', $result->currentPeriod->label); + self::assertTrue($result->currentPeriod->isCurrent); + } + + private function setTenant(): void + { + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + subdomain: 'test', + databaseUrl: 'sqlite:///:memory:', + )); + } + + private function createProvider(bool $granted): PeriodsProvider + { + $authChecker = new class($granted) implements AuthorizationCheckerInterface { + public function __construct(private readonly bool $granted) + { + } + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->granted; + } + }; + + $handler = new GetPeriodsHandler($this->repository, $this->clock); + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + + return new PeriodsProvider($handler, $this->tenantContext, $authChecker, $resolver); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Service/CurrentAcademicYearResolverTest.php b/backend/tests/Unit/Administration/Infrastructure/Service/CurrentAcademicYearResolverTest.php new file mode 100644 index 0000000..c904027 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Service/CurrentAcademicYearResolverTest.php @@ -0,0 +1,214 @@ +createResolver('2026-02-05 10:00:00'); + $uuid = '550e8400-e29b-41d4-a716-446655440099'; + + self::assertSame($uuid, $resolver->resolve($uuid)); + } + + #[Test] + public function itReturnsNullForInvalidIdentifier(): void + { + $resolver = $this->createResolver('2026-02-05 10:00:00'); + + self::assertNull($resolver->resolve('invalid')); + self::assertNull($resolver->resolve('')); + self::assertNull($resolver->resolve('past')); + } + + #[Test] + public function itResolvesCurrentBeforeSeptember(): void + { + // February 2026 → school year 2025-2026 + $resolver = $this->createResolver('2026-02-05 10:00:00'); + $result = $resolver->resolve('current'); + + self::assertNotNull($result); + self::assertTrue(\Ramsey\Uuid\Uuid::isValid($result)); + } + + #[Test] + public function itResolvesCurrentAfterSeptember(): void + { + // October 2025 → school year 2025-2026 + $resolver = $this->createResolver('2025-10-15 10:00:00'); + $result = $resolver->resolve('current'); + + self::assertNotNull($result); + self::assertTrue(\Ramsey\Uuid\Uuid::isValid($result)); + } + + #[Test] + public function itResolvesSameUuidForSameSchoolYear(): void + { + // Both dates are in school year 2025-2026 + $resolverOct = $this->createResolver('2025-10-15 10:00:00'); + $resolverFeb = $this->createResolver('2026-02-05 10:00:00'); + + self::assertSame( + $resolverOct->resolve('current'), + $resolverFeb->resolve('current'), + ); + } + + #[Test] + public function itResolvesDifferentUuidForDifferentSchoolYears(): void + { + // October 2025 → 2025-2026, October 2026 → 2026-2027 + $resolver2025 = $this->createResolver('2025-10-15 10:00:00'); + $resolver2026 = $this->createResolver('2026-10-15 10:00:00'); + + self::assertNotSame( + $resolver2025->resolve('current'), + $resolver2026->resolve('current'), + ); + } + + #[Test] + public function itResolvesNextYear(): void + { + // February 2026, current = 2025-2026, next = 2026-2027 + $resolver = $this->createResolver('2026-02-05 10:00:00'); + + $current = $resolver->resolve('current'); + $next = $resolver->resolve('next'); + + self::assertNotNull($next); + self::assertTrue(\Ramsey\Uuid\Uuid::isValid($next)); + self::assertNotSame($current, $next); + } + + #[Test] + public function itResolvesPreviousYear(): void + { + // February 2026, current = 2025-2026, previous = 2024-2025 + $resolver = $this->createResolver('2026-02-05 10:00:00'); + + $current = $resolver->resolve('current'); + $previous = $resolver->resolve('previous'); + + self::assertNotNull($previous); + self::assertTrue(\Ramsey\Uuid\Uuid::isValid($previous)); + self::assertNotSame($current, $previous); + } + + #[Test] + public function nextOfCurrentYearMatchesCurrentOfNextYear(): void + { + // "next" from Feb 2026 should equal "current" from Oct 2026 + $resolverFeb2026 = $this->createResolver('2026-02-05 10:00:00'); + $resolverOct2026 = $this->createResolver('2026-10-15 10:00:00'); + + self::assertSame( + $resolverFeb2026->resolve('next'), + $resolverOct2026->resolve('current'), + ); + } + + #[Test] + public function previousOfCurrentYearMatchesCurrentOfPreviousYear(): void + { + // "previous" from Feb 2026 (2024-2025) should equal "current" from Oct 2024 + $resolverFeb2026 = $this->createResolver('2026-02-05 10:00:00'); + $resolverOct2024 = $this->createResolver('2024-10-15 10:00:00'); + + self::assertSame( + $resolverFeb2026->resolve('previous'), + $resolverOct2024->resolve('current'), + ); + } + + #[Test] + public function itResolvesDifferentUuidsForDifferentTenants(): void + { + $otherTenantUuid = '550e8400-e29b-41d4-a716-446655440099'; + + $resolver1 = $this->createResolver('2026-02-05 10:00:00', self::TENANT_UUID); + $resolver2 = $this->createResolver('2026-02-05 10:00:00', $otherTenantUuid); + + self::assertNotSame( + $resolver1->resolve('current'), + $resolver2->resolve('current'), + ); + } + + #[Test] + public function itIsDeterministic(): void + { + $resolver = $this->createResolver('2026-02-05 10:00:00'); + + self::assertSame( + $resolver->resolve('current'), + $resolver->resolve('current'), + ); + } + + #[Test] + public function septemberBelongsToNewSchoolYear(): void + { + // September 1st 2026 should be in school year 2026-2027 + $resolverSept = $this->createResolver('2026-09-01 08:00:00'); + $resolverOct = $this->createResolver('2026-10-15 10:00:00'); + + self::assertSame( + $resolverSept->resolve('current'), + $resolverOct->resolve('current'), + ); + } + + #[Test] + public function augustBelongsToPreviousSchoolYear(): void + { + // August 31st 2026 should still be in school year 2025-2026 + $resolverAug = $this->createResolver('2026-08-31 23:59:59'); + $resolverFeb = $this->createResolver('2026-02-05 10:00:00'); + + self::assertSame( + $resolverAug->resolve('current'), + $resolverFeb->resolve('current'), + ); + } + + private function createResolver(string $dateTime, string $tenantUuid = self::TENANT_UUID): CurrentAcademicYearResolver + { + $tenantContext = new TenantContext(); + $tenantContext->setCurrentTenant(new TenantConfig( + tenantId: TenantId::fromString($tenantUuid), + subdomain: 'test', + databaseUrl: 'sqlite:///:memory:', + )); + + $clock = new class($dateTime) implements Clock { + public function __construct(private readonly string $dateTime) + { + } + + public function now(): DateTimeImmutable + { + return new DateTimeImmutable($this->dateTime); + } + }; + + return new CurrentAcademicYearResolver($tenantContext, $clock); + } +} diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts index b924f09..b61b5e0 100644 --- a/frontend/e2e/classes.spec.ts +++ b/frontend/e2e/classes.spec.ts @@ -433,8 +433,8 @@ test.describe('Classes Management (Story 2.1)', () => { const classCard = page.locator('.class-card', { hasText: className }); await classCard.getByRole('button', { name: /modifier/i }).click(); - // Click breadcrumb to go back - await page.getByRole('link', { name: 'Classes' }).click(); + // Click breadcrumb to go back (scoped to main to avoid matching nav link) + await page.getByRole('main').getByRole('link', { name: 'Classes' }).click(); await expect(page).toHaveURL(/\/admin\/classes$/); }); diff --git a/frontend/e2e/periods.spec.ts b/frontend/e2e/periods.spec.ts new file mode 100644 index 0000000..9fa1469 --- /dev/null +++ b/frontend/e2e/periods.spec.ts @@ -0,0 +1,312 @@ +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-periods-admin@example.com'; +const ADMIN_PASSWORD = 'PeriodsTest123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +// Force serial execution — empty state must run first +test.describe.configure({ mode: 'serial' }); + +test.describe('Periods Management (Story 2.3)', () => { + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + try { + // Create admin user + 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' } + ); + console.log('Periods E2E test admin user created'); + + // Clean up all periods for this tenant + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`, + { encoding: 'utf-8' } + ); + console.log('Periods cleaned up for E2E tests'); + } catch (error) { + console.error('Setup error:', error); + } + }); + + 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 page.getByRole('button', { name: /se connecter/i }).click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + } + + // ============================================================================ + // Empty State + // ============================================================================ + test.describe('Empty State', () => { + test('shows empty state when no periods 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 academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Ignore cleanup errors + } + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible(); + await expect(page.getByText(/aucune période configurée/i)).toBeVisible(); + await expect( + page.getByRole('button', { name: /configurer les périodes/i }) + ).toBeVisible(); + }); + }); + + // ============================================================================ + // Year Selector Tabs + // ============================================================================ + test.describe('Year Selector', () => { + test('displays three year tabs', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + const tabs = page.getByRole('tab'); + await expect(tabs).toHaveCount(3); + }); + + test('current year tab is active by default', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + const tabs = page.getByRole('tab'); + // Middle tab (current) should be active + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true'); + }); + + test('can switch between year tabs', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + const tabs = page.getByRole('tab'); + + // Wait for Svelte hydration and initial load to complete + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + await page.waitForLoadState('networkidle'); + + // Click next year tab + await tabs.nth(2).click(); + await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + + // Wait for load triggered by tab switch + await page.waitForLoadState('networkidle'); + + // Click previous year tab + await tabs.nth(0).click(); + await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + }); + }); + + // ============================================================================ + // Period Configuration + // ============================================================================ + test.describe('Period Configuration', () => { + test('can configure trimesters', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + // Click "Configurer les périodes" button + await page.getByRole('button', { name: /configurer les périodes/i }).click(); + + // Modal should open + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Select trimester (should be default) + await expect(dialog.locator('#period-type')).toHaveValue('trimester'); + + // Verify preview shows 3 trimesters + await expect(dialog.getByText(/T1/)).toBeVisible(); + await expect(dialog.getByText(/T2/)).toBeVisible(); + await expect(dialog.getByText(/T3/)).toBeVisible(); + + // Submit + await dialog.getByRole('button', { name: /configurer$/i }).click(); + + // Modal should close + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Period cards should appear + await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('heading', { name: 'T2' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'T3' })).toBeVisible(); + }); + + test('shows trimester badge after configuration', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + await expect(page.getByText(/trimestres/i)).toBeVisible({ timeout: 10000 }); + }); + + test('shows dates on each period card', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + // Wait for periods to load + await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 }); + + // Each period card should have start and end dates + const periodCards = page.locator('.period-card'); + const count = await periodCards.count(); + expect(count).toBe(3); + + // Verify date labels exist + await expect(page.getByText(/début/i).first()).toBeVisible(); + await expect(page.getByText(/fin/i).first()).toBeVisible(); + }); + + test('configure button no longer visible when periods exist', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + // Wait for periods to load + await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 }); + + // Configure button should not be visible + await expect( + page.getByRole('button', { name: /configurer les périodes/i }) + ).not.toBeVisible(); + }); + + test('can configure semesters on next year', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + // Wait for initial load to complete before switching tab + const tabs = page.getByRole('tab'); + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + await page.waitForLoadState('networkidle'); + + // Switch to next year tab + await tabs.nth(2).click(); + + // Should show empty state for next year + await expect(page.getByText(/aucune période configurée/i)).toBeVisible({ + timeout: 10000 + }); + + // Configure semesters for next year + await page.getByRole('button', { name: /configurer les périodes/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Select semester + await dialog.locator('#period-type').selectOption('semester'); + + // Verify preview shows 2 semesters + await expect(dialog.getByText(/S1/)).toBeVisible(); + await expect(dialog.getByText(/S2/)).toBeVisible(); + + // Submit + await dialog.getByRole('button', { name: /configurer$/i }).click(); + + // Modal should close and period cards appear + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('heading', { name: 'S1' })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('heading', { name: 'S2' })).toBeVisible(); + }); + }); + + // ============================================================================ + // Period Date Modification + // ============================================================================ + test.describe('Period Date Modification', () => { + test('each period card has a modify button', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 }); + + const modifyButtons = page.getByRole('button', { name: /modifier les dates/i }); + await expect(modifyButtons).toHaveCount(3); + }); + + test('opens edit modal when clicking modify', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 }); + + // Click modify on first period + const modifyButtons = page.getByRole('button', { name: /modifier les dates/i }); + await modifyButtons.first().click(); + + // Edit modal should open + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await expect(dialog.getByText(/modifier T1/i)).toBeVisible(); + + // Date fields should be present + await expect(dialog.locator('#edit-start-date')).toBeVisible(); + await expect(dialog.locator('#edit-end-date')).toBeVisible(); + }); + + test('can cancel date modification', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 }); + + const modifyButtons = page.getByRole('button', { name: /modifier les dates/i }); + await modifyButtons.first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Cancel + await dialog.getByRole('button', { name: /annuler/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + }); + + // ============================================================================ + // Navigation + // ============================================================================ + test.describe('Navigation', () => { + test('can access periods page from admin dashboard', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin`); + + // Click on periods card + await page.getByRole('link', { name: /périodes scolaires/i }).click(); + + await expect(page).toHaveURL(/\/admin\/academic-year\/periods/); + await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible(); + }); + + test('can access periods page directly', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); + + await expect(page).toHaveURL(/\/admin\/academic-year\/periods/); + await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible(); + }); + }); +}); diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index 38f7dfb..ac541fa 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -41,11 +41,11 @@ Gérer les matières Créer et gérer -
+ 📅 - Calendrier scolaire - Bientôt disponible -
+ Périodes scolaires + Trimestres et semestres +
📤 Importer des données diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index e8b8b8c..e5b2d07 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -26,6 +26,7 @@ // Determine which admin section is active const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes')); const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects')); + const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
@@ -38,6 +39,7 @@ Tableau de bord Classes Matières + Périodes + {/each} +
+
+ + + {#if error} +
+ ! + {error} + +
+ {/if} + + {#if isLoading} +
+
+

Chargement des périodes...

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

Aucune période configurée

+

Choisissez entre trimestres (3 périodes) ou semestres (2 périodes)

+ +
+ {:else if config} + + {#if config.currentPeriod} +
+ + +
+ {/if} + + +
+ {typeLabel(config.type)} +
+ + +
+ {#each config.periods as period (period.sequence)} +
+
+

{period.label}

+ {#if period.isCurrent} + En cours + {:else if period.isPast} + Terminée + {:else} + A venir + {/if} +
+ +
+
+ Début + {formatDate(period.startDate)} +
+
+ Fin + {formatDate(period.endDate)} +
+
+ + {#if period.isCurrent} +
+
+ {period.daysRemaining} jours restants +
+
+ {/if} + +
+ +
+
+ {/each} +
+ {/if} + + + +{#if showConfigureModal} + +{/if} + + +{#if showEditModal && editingPeriod} + +{/if} + + diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index af6e946..8d9824b 100644 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -1,8 +1,12 @@ #!/bin/bash -# Pre-push hook: runs CI checks and E2E tests before pushing +# Pre-push hook: runs CI checks before pushing # This ensures code quality and prevents broken builds on the remote. # +# E2E tests are excluded because they take ~15 min, which causes +# the SSH connection to GitHub to timeout (SIGPIPE). +# Run them separately with: make e2e +# # Install: make setup-hooks # Skip: git push --no-verify @@ -11,7 +15,4 @@ set -e echo "🔍 Running CI checks before push..." make ci -echo "🧪 Running E2E tests..." -make e2e - echo "✅ All checks passed! Pushing..."