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 -
+ Périodes scolaires + Trimestres et semestres +