diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 1394a53..cfc71da 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -26,6 +26,7 @@ services: # Bind named message buses Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus' Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus' + Symfony\Component\Messenger\MessageBusInterface $queryBus: '@query.bus' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -125,6 +126,10 @@ services: App\Administration\Domain\Repository\SessionRepository: alias: App\Administration\Infrastructure\Persistence\Redis\RedisSessionRepository + # Class Repository (Story 2.1 - Gestion des classes) + App\Administration\Domain\Repository\ClassRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineClassRepository + # GeoLocation Service (null implementation - no geolocation) App\Administration\Application\Port\GeoLocationService: alias: App\Administration\Infrastructure\Service\NullGeoLocationService diff --git a/backend/migrations/Version20260204100000.php b/backend/migrations/Version20260204100000.php new file mode 100644 index 0000000..66a6ef9 --- /dev/null +++ b/backend/migrations/Version20260204100000.php @@ -0,0 +1,59 @@ +addSql(<<<'SQL' + CREATE TABLE school_classes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + school_id UUID NOT NULL, + academic_year_id UUID NOT NULL, + name VARCHAR(50) NOT NULL, + level VARCHAR(50), + capacity INT, + status VARCHAR(20) NOT NULL DEFAULT 'active', + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + UNIQUE(tenant_id, academic_year_id, name) + ) + SQL); + + // Index pour les requêtes fréquentes + $this->addSql('CREATE INDEX idx_school_classes_tenant_id ON school_classes(tenant_id)'); + $this->addSql('CREATE INDEX idx_school_classes_academic_year ON school_classes(academic_year_id)'); + $this->addSql('CREATE INDEX idx_school_classes_status ON school_classes(status)'); + $this->addSql('CREATE INDEX idx_school_classes_school ON school_classes(school_id)'); + + // Index composite pour les requêtes de liste par tenant et année + $this->addSql('CREATE INDEX idx_school_classes_tenant_year_status ON school_classes(tenant_id, academic_year_id, status)'); + + $this->addSql("COMMENT ON TABLE school_classes IS 'Classes scolaires organisées par année académique et établissement (FR73)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS school_classes'); + } +} diff --git a/backend/migrations/Version20260205100000.php b/backend/migrations/Version20260205100000.php new file mode 100644 index 0000000..eed1609 --- /dev/null +++ b/backend/migrations/Version20260205100000.php @@ -0,0 +1,48 @@ +addSql('ALTER TABLE school_classes DROP CONSTRAINT IF EXISTS school_classes_tenant_id_academic_year_id_name_key'); + + // Créer un index unique partiel qui exclut les classes archivées + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX idx_school_classes_unique_name_active + ON school_classes (tenant_id, academic_year_id, name) + WHERE deleted_at IS NULL + SQL); + } + + public function down(Schema $schema): void + { + // Supprimer l'index partiel + $this->addSql('DROP INDEX IF EXISTS idx_school_classes_unique_name_active'); + + // Restaurer la contrainte unique originale + $this->addSql('ALTER TABLE school_classes ADD CONSTRAINT school_classes_tenant_id_academic_year_id_name_key UNIQUE (tenant_id, academic_year_id, name)'); + } +} diff --git a/backend/src/Administration/Application/Command/ArchiveClass/ArchiveClassCommand.php b/backend/src/Administration/Application/Command/ArchiveClass/ArchiveClassCommand.php new file mode 100644 index 0000000..301bf24 --- /dev/null +++ b/backend/src/Administration/Application/Command/ArchiveClass/ArchiveClassCommand.php @@ -0,0 +1,16 @@ +classId); + $class = $this->classRepository->get($classId); + + // Vérifier s'il y a des élèves affectés + $envelope = $this->queryBus->dispatch(new HasStudentsInClassQuery($command->classId)); + /** @var HandledStamp|null $handledStamp */ + $handledStamp = $envelope->last(HandledStamp::class); + + if ($handledStamp !== null) { + /** @var int $studentCount */ + $studentCount = $handledStamp->getResult(); + + if ($studentCount > 0) { + throw ClasseNonSupprimableException::carElevesAffectes($classId, $studentCount); + } + } + + $class->archiver($this->clock->now()); + + $this->classRepository->save($class); + + return $class; + } +} diff --git a/backend/src/Administration/Application/Command/CreateClass/CreateClassCommand.php b/backend/src/Administration/Application/Command/CreateClass/CreateClassCommand.php new file mode 100644 index 0000000..fa74414 --- /dev/null +++ b/backend/src/Administration/Application/Command/CreateClass/CreateClassCommand.php @@ -0,0 +1,21 @@ +tenantId); + $academicYearId = AcademicYearId::fromString($command->academicYearId); + $name = new ClassName($command->name); + + // Vérifier l'unicité du nom dans le tenant et l'année scolaire + $existingClass = $this->classRepository->findByName($name, $tenantId, $academicYearId); + if ($existingClass !== null) { + throw ClasseDejaExistanteException::avecNom($name); + } + + $class = SchoolClass::creer( + tenantId: $tenantId, + schoolId: SchoolId::fromString($command->schoolId), + academicYearId: $academicYearId, + name: $name, + level: $command->level !== null ? SchoolLevel::from($command->level) : null, + capacity: $command->capacity, + createdAt: $this->clock->now(), + ); + + $this->classRepository->save($class); + + return $class; + } +} diff --git a/backend/src/Administration/Application/Command/CreateClass/CreateClassResult.php b/backend/src/Administration/Application/Command/CreateClass/CreateClassResult.php new file mode 100644 index 0000000..78838a7 --- /dev/null +++ b/backend/src/Administration/Application/Command/CreateClass/CreateClassResult.php @@ -0,0 +1,19 @@ +classRepository->get(ClassId::fromString($command->classId)); + $now = $this->clock->now(); + + if ($command->name !== null) { + $newName = new ClassName($command->name); + + // Vérifier l'unicité du nouveau nom (sauf si c'est le même) + if (!$class->name->equals($newName)) { + $existingClass = $this->classRepository->findByName( + $newName, + $class->tenantId, + $class->academicYearId, + ); + + if ($existingClass !== null && !$existingClass->id->equals($class->id)) { + throw ClasseDejaExistanteException::avecNom($newName); + } + } + + $class->renommer($newName, $now); + } + + if ($command->level !== null) { + $class->changerNiveau(SchoolLevel::from($command->level), $now); + } elseif ($command->clearLevel) { + $class->changerNiveau(null, $now); + } + + if ($command->capacity !== null) { + $class->changerCapacite($command->capacity, $now); + } elseif ($command->clearCapacity) { + $class->changerCapacite(null, $now); + } + + if ($command->description !== null) { + $class->decrire($command->description, $now); + } elseif ($command->clearDescription) { + $class->decrire(null, $now); + } + + $this->classRepository->save($class); + + return $class; + } +} diff --git a/backend/src/Administration/Application/Query/GetClasses/ClassDto.php b/backend/src/Administration/Application/Query/GetClasses/ClassDto.php new file mode 100644 index 0000000..336f6f6 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetClasses/ClassDto.php @@ -0,0 +1,40 @@ +id, + name: (string) $class->name, + level: $class->level?->value, + capacity: $class->capacity, + status: $class->status->value, + description: $class->description, + createdAt: $class->createdAt, + updatedAt: $class->updatedAt, + ); + } +} diff --git a/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php b/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php new file mode 100644 index 0000000..f45f273 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php @@ -0,0 +1,38 @@ +classRepository->findActiveByTenantAndYear( + TenantId::fromString($query->tenantId), + AcademicYearId::fromString($query->academicYearId), + ); + + return array_map( + static fn ($class) => ClassDto::fromDomain($class), + $classes, + ); + } +} diff --git a/backend/src/Administration/Application/Query/GetClasses/GetClassesQuery.php b/backend/src/Administration/Application/Query/GetClasses/GetClassesQuery.php new file mode 100644 index 0000000..96fe67b --- /dev/null +++ b/backend/src/Administration/Application/Query/GetClasses/GetClassesQuery.php @@ -0,0 +1,17 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->classId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/ClasseCreee.php b/backend/src/Administration/Domain/Event/ClasseCreee.php new file mode 100644 index 0000000..0cfe7a0 --- /dev/null +++ b/backend/src/Administration/Domain/Event/ClasseCreee.php @@ -0,0 +1,41 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->classId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/ClasseModifiee.php b/backend/src/Administration/Domain/Event/ClasseModifiee.php new file mode 100644 index 0000000..44c9cb8 --- /dev/null +++ b/backend/src/Administration/Domain/Event/ClasseModifiee.php @@ -0,0 +1,40 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->classId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/ClassNameInvalideException.php b/backend/src/Administration/Domain/Exception/ClassNameInvalideException.php new file mode 100644 index 0000000..22768cc --- /dev/null +++ b/backend/src/Administration/Domain/Exception/ClassNameInvalideException.php @@ -0,0 +1,22 @@ + self::MAX_LENGTH) { + throw ClassNameInvalideException::pourLongueur($value, self::MIN_LENGTH, self::MAX_LENGTH); + } + + // After validation, $trimmed is guaranteed to be non-empty (MIN_LENGTH >= 2) + assert($trimmed !== ''); + $this->value = $trimmed; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + /** + * @return non-empty-string + */ + public function __toString(): string + { + return $this->value; + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolClass/ClassStatus.php b/backend/src/Administration/Domain/Model/SchoolClass/ClassStatus.php new file mode 100644 index 0000000..ebf92c1 --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolClass/ClassStatus.php @@ -0,0 +1,41 @@ + 'Active', + self::ARCHIVED => 'Archivée', + }; + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolClass/SchoolClass.php b/backend/src/Administration/Domain/Model/SchoolClass/SchoolClass.php new file mode 100644 index 0000000..02281af --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolClass/SchoolClass.php @@ -0,0 +1,210 @@ +updatedAt = $createdAt; + } + + /** + * Crée une nouvelle classe scolaire. + */ + public static function creer( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ClassName $name, + ?SchoolLevel $level, + ?int $capacity, + DateTimeImmutable $createdAt, + ): self { + $class = new self( + id: ClassId::generate(), + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + name: $name, + level: $level, + capacity: $capacity, + status: ClassStatus::ACTIVE, + createdAt: $createdAt, + ); + + $class->recordEvent(new ClasseCreee( + classId: $class->id, + tenantId: $class->tenantId, + name: $class->name, + level: $class->level, + occurredOn: $createdAt, + )); + + return $class; + } + + /** + * Renomme la classe. + */ + public function renommer(ClassName $nouveauNom, DateTimeImmutable $at): void + { + if ($this->name->equals($nouveauNom)) { + return; + } + + $ancienNom = $this->name; + $this->name = $nouveauNom; + $this->updatedAt = $at; + + $this->recordEvent(new ClasseModifiee( + classId: $this->id, + tenantId: $this->tenantId, + ancienNom: $ancienNom, + nouveauNom: $nouveauNom, + occurredOn: $at, + )); + } + + /** + * Modifie le niveau scolaire de la classe. + */ + public function changerNiveau(?SchoolLevel $niveau, DateTimeImmutable $at): void + { + if ($this->level === $niveau) { + return; + } + + $this->level = $niveau; + $this->updatedAt = $at; + } + + /** + * Modifie la capacité maximale de la classe. + */ + public function changerCapacite(?int $capacity, DateTimeImmutable $at): void + { + if ($this->capacity === $capacity) { + return; + } + + $this->capacity = $capacity; + $this->updatedAt = $at; + } + + /** + * Ajoute ou modifie la description de la classe. + */ + public function decrire(?string $description, DateTimeImmutable $at): void + { + $this->description = $description; + $this->updatedAt = $at; + } + + /** + * Archive la classe (soft delete). + * + * Note: La vérification des élèves affectés doit être faite par l'Application Layer + * via une Query avant d'appeler cette méthode. + */ + public function archiver(DateTimeImmutable $at): void + { + if ($this->status === ClassStatus::ARCHIVED) { + return; + } + + $this->status = ClassStatus::ARCHIVED; + $this->deletedAt = $at; + $this->updatedAt = $at; + + $this->recordEvent(new ClasseArchivee( + classId: $this->id, + tenantId: $this->tenantId, + occurredOn: $at, + )); + } + + /** + * Vérifie si la classe est active. + */ + public function estActive(): bool + { + return $this->status === ClassStatus::ACTIVE; + } + + /** + * Vérifie si la classe peut recevoir des élèves. + */ + public function peutRecevoirEleves(): bool + { + return $this->status->peutRecevoirEleves(); + } + + /** + * Reconstitue une SchoolClass depuis le stockage. + * + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + ClassId $id, + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ClassName $name, + ?SchoolLevel $level, + ?int $capacity, + ClassStatus $status, + ?string $description, + DateTimeImmutable $createdAt, + DateTimeImmutable $updatedAt, + ?DateTimeImmutable $deletedAt, + ): self { + $class = new self( + id: $id, + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + name: $name, + level: $level, + capacity: $capacity, + status: $status, + createdAt: $createdAt, + ); + + $class->description = $description; + $class->updatedAt = $updatedAt; + $class->deletedAt = $deletedAt; + + return $class; + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolClass/SchoolId.php b/backend/src/Administration/Domain/Model/SchoolClass/SchoolId.php new file mode 100644 index 0000000..6a08af4 --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolClass/SchoolId.php @@ -0,0 +1,14 @@ +value; + } + + /** + * Détermine si le niveau appartient au cycle primaire. + */ + public function estPrimaire(): bool + { + return match ($this) { + self::CP, self::CE1, self::CE2, self::CM1, self::CM2 => true, + default => false, + }; + } + + /** + * Détermine si le niveau appartient au cycle collège. + */ + public function estCollege(): bool + { + return match ($this) { + self::SIXIEME, self::CINQUIEME, self::QUATRIEME, self::TROISIEME => true, + default => false, + }; + } + + /** + * Détermine si le niveau appartient au cycle lycée. + */ + public function estLycee(): bool + { + return match ($this) { + self::SECONDE, self::PREMIERE, self::TERMINALE => true, + default => false, + }; + } + + /** + * Retourne le cycle d'enseignement. + */ + public function cycle(): string + { + return match ($this) { + self::CP, self::CE1, self::CE2, self::CM1, self::CM2 => 'Primaire', + self::SIXIEME, self::CINQUIEME, self::QUATRIEME, self::TROISIEME => 'Collège', + self::SECONDE, self::PREMIERE, self::TERMINALE => 'Lycée', + }; + } +} diff --git a/backend/src/Administration/Domain/Model/SchoolClass/SchoolLevels.php b/backend/src/Administration/Domain/Model/SchoolClass/SchoolLevels.php new file mode 100644 index 0000000..a5f64cf --- /dev/null +++ b/backend/src/Administration/Domain/Model/SchoolClass/SchoolLevels.php @@ -0,0 +1,34 @@ + + */ + public const array ALL = [ + 'CP', + 'CE1', + 'CE2', + 'CM1', + 'CM2', + '6ème', + '5ème', + '4ème', + '3ème', + '2nde', + '1ère', + 'Terminale', + ]; +} diff --git a/backend/src/Administration/Domain/Repository/ClassRepository.php b/backend/src/Administration/Domain/Repository/ClassRepository.php new file mode 100644 index 0000000..cb123ff --- /dev/null +++ b/backend/src/Administration/Domain/Repository/ClassRepository.php @@ -0,0 +1,47 @@ + + */ +final readonly class CreateClassProcessor implements ProcessorInterface +{ + public function __construct( + private CreateClassHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @param ClassResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClassResource + { + if (!$this->authorizationChecker->isGranted(ClassVoter::CREATE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer une classe.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + // TODO: Récupérer school_id et academic_year_id depuis le contexte utilisateur + // quand les modules Schools et AcademicYears seront implémentés. + // Pour l'instant, on utilise des UUIDs déterministes basés sur le tenant. + $schoolId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString(); + $academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString(); + + try { + $command = new CreateClassCommand( + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + name: $data->name ?? '', + level: $data->level, + capacity: $data->capacity, + ); + + $class = ($this->handler)($command); + + // Dispatch domain events from the created aggregate + foreach ($class->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + // Return the created resource + $resource = new ClassResource(); + $resource->id = (string) $class->id; + $resource->name = (string) $class->name; + $resource->level = $class->level?->value; + $resource->capacity = $class->capacity; + $resource->status = $class->status->value; + + return $resource; + } catch (ClassNameInvalideException $e) { + throw new BadRequestHttpException($e->getMessage()); + } catch (ClasseDejaExistanteException $e) { + throw new ConflictHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/DeleteClassProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/DeleteClassProcessor.php new file mode 100644 index 0000000..ac3df31 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/DeleteClassProcessor.php @@ -0,0 +1,81 @@ + + */ +final readonly class DeleteClassProcessor implements ProcessorInterface +{ + public function __construct( + private ArchiveClassHandler $handler, + private ClassRepository $classRepository, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @param ClassResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null + { + $classId = $uriVariables['id'] ?? null; + + if (!is_string($classId)) { + throw new BadRequestHttpException('ID de classe manquant.'); + } + + // Vérifier les permissions avant toute action + $class = $this->classRepository->findById(ClassId::fromString($classId)); + if ($class === null) { + throw new NotFoundHttpException('Classe introuvable.'); + } + + if (!$this->authorizationChecker->isGranted(ClassVoter::DELETE, $class)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer cette classe.'); + } + + try { + $command = new ArchiveClassCommand(classId: $classId); + + $archivedClass = ($this->handler)($command); + + // Dispatch domain events from the archived aggregate + foreach ($archivedClass->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return null; + } catch (ClasseNotFoundException) { + throw new NotFoundHttpException('Classe introuvable.'); + } catch (ClasseNonSupprimableException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/UpdateClassProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/UpdateClassProcessor.php new file mode 100644 index 0000000..5fb9ba1 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/UpdateClassProcessor.php @@ -0,0 +1,105 @@ + + */ +final readonly class UpdateClassProcessor implements ProcessorInterface +{ + public function __construct( + private UpdateClassHandler $handler, + private ClassRepository $classRepository, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @param ClassResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClassResource + { + $classId = $uriVariables['id'] ?? null; + + if (!is_string($classId)) { + throw new BadRequestHttpException('ID de classe manquant.'); + } + + // Vérifier les permissions avant toute action + $class = $this->classRepository->findById(ClassId::fromString($classId)); + if ($class === null) { + throw new NotFoundHttpException('Classe introuvable.'); + } + + if (!$this->authorizationChecker->isGranted(ClassVoter::EDIT, $class)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier cette classe.'); + } + + try { + $command = new UpdateClassCommand( + classId: $classId, + name: $data->name, + level: $data->level, + capacity: $data->capacity, + description: $data->description, + clearLevel: $data->clearLevel ?? false, + clearCapacity: $data->clearCapacity ?? false, + clearDescription: $data->clearDescription ?? false, + ); + + $updatedClass = ($this->handler)($command); + + // Dispatch domain events from the mutated aggregate + foreach ($updatedClass->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + // Return updated resource + $resource = new ClassResource(); + $resource->id = (string) $updatedClass->id; + $resource->name = (string) $updatedClass->name; + $resource->level = $updatedClass->level?->value; + $resource->capacity = $updatedClass->capacity; + $resource->description = $updatedClass->description; + $resource->status = $updatedClass->status->value; + $resource->createdAt = $updatedClass->createdAt; + $resource->updatedAt = $updatedClass->updatedAt; + + return $resource; + } catch (ClasseNotFoundException) { + throw new NotFoundHttpException('Classe introuvable.'); + } catch (ClassNameInvalideException $e) { + throw new BadRequestHttpException($e->getMessage()); + } catch (ClasseDejaExistanteException $e) { + throw new ConflictHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php new file mode 100644 index 0000000..673e164 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php @@ -0,0 +1,79 @@ + + */ +final readonly class ClassCollectionProvider implements ProviderInterface +{ + public function __construct( + private GetClassesHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @return ClassResource[] + */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + // Vérifier les permissions de lecture (sans sujet spécifique) + if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les classes.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + // TODO: Récupérer academic_year_id depuis le contexte utilisateur + // quand le module AcademicYears sera implémenté. + $academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString(); + + $query = new GetClassesQuery( + tenantId: $tenantId, + academicYearId: $academicYearId, + ); + + $classDtos = ($this->handler)($query); + + return array_map( + static function ($dto) { + $resource = new ClassResource(); + $resource->id = $dto->id; + $resource->name = $dto->name; + $resource->level = $dto->level; + $resource->capacity = $dto->capacity; + $resource->description = $dto->description; + $resource->status = $dto->status; + $resource->createdAt = $dto->createdAt; + $resource->updatedAt = $dto->updatedAt; + + return $resource; + }, + $classDtos, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/ClassItemProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/ClassItemProvider.php new file mode 100644 index 0000000..2b3ac44 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/ClassItemProvider.php @@ -0,0 +1,85 @@ + + */ +final readonly class ClassItemProvider implements ProviderInterface +{ + public function __construct( + private ClassRepository $classRepository, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?ClassResource + { + $id = $uriVariables['id'] ?? null; + + if (!is_string($id)) { + return null; + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + try { + $classId = ClassId::fromString($id); + } catch (InvalidArgumentException) { + throw new NotFoundHttpException('Classe introuvable.'); + } + + $class = $this->classRepository->findById($classId); + + if ($class === null) { + throw new NotFoundHttpException('Classe introuvable.'); + } + + // Vérifier que la classe appartient au tenant courant (comparaison par valeur string) + if ((string) $class->tenantId !== (string) $this->tenantContext->getCurrentTenantId()) { + throw new NotFoundHttpException('Classe introuvable.'); + } + + // Vérifier les permissions de lecture + if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW, $class)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette classe.'); + } + + $resource = new ClassResource(); + $resource->id = (string) $class->id; + $resource->name = (string) $class->name; + $resource->level = $class->level?->value; + $resource->capacity = $class->capacity; + $resource->description = $class->description; + $resource->status = $class->status->value; + $resource->createdAt = $class->createdAt; + $resource->updatedAt = $class->updatedAt; + + return $resource; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/ClassResource.php b/backend/src/Administration/Infrastructure/Api/Resource/ClassResource.php new file mode 100644 index 0000000..1c76d3e --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/ClassResource.php @@ -0,0 +1,114 @@ + ['Default', 'create']], + name: 'create_class', + ), + new Patch( + uriTemplate: '/classes/{id}', + provider: ClassItemProvider::class, + processor: UpdateClassProcessor::class, + validationContext: ['groups' => ['Default', 'update']], + name: 'update_class', + ), + new Delete( + uriTemplate: '/classes/{id}', + provider: ClassItemProvider::class, + processor: DeleteClassProcessor::class, + name: 'delete_class', + ), + ], +)] +final class ClassResource +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + #[Assert\NotBlank(message: 'Le nom de la classe est requis.', groups: ['create'])] + #[Assert\Length( + min: 2, + max: 50, + minMessage: 'Le nom de la classe doit contenir au moins {{ limit }} caractères.', + maxMessage: 'Le nom de la classe ne peut pas dépasser {{ limit }} caractères.', + )] + public ?string $name = null; + + #[Assert\Choice( + choices: SchoolLevels::ALL, + message: 'Le niveau scolaire doit être un niveau valide.', + )] + public ?string $level = null; + + #[Assert\PositiveOrZero(message: 'La capacité doit être un nombre positif.')] + public ?int $capacity = null; + + public ?string $description = null; + + public ?string $status = null; + + public ?DateTimeImmutable $createdAt = null; + + public ?DateTimeImmutable $updatedAt = null; + + /** + * Permet de supprimer explicitement le niveau lors d'un PATCH. + * Si true, le niveau sera mis à null même si level n'est pas fourni. + */ + #[ApiProperty(readable: false)] + public ?bool $clearLevel = null; + + /** + * Permet de supprimer explicitement la capacité lors d'un PATCH. + * Si true, la capacité sera mise à null même si capacity n'est pas fourni. + */ + #[ApiProperty(readable: false)] + public ?bool $clearCapacity = null; + + /** + * Permet de supprimer explicitement la description lors d'un PATCH. + * Si true, la description sera mise à null même si description n'est pas fourni. + */ + #[ApiProperty(readable: false)] + public ?bool $clearDescription = null; +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php new file mode 100644 index 0000000..8badebc --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php @@ -0,0 +1,180 @@ + (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); + } + } + + #[Override] + public function get(ClassId $id): SchoolClass + { + $class = $this->findById($id); + + if ($class === null) { + throw ClasseNotFoundException::withId($id); + } + + return $class; + } + + #[Override] + public function findById(ClassId $id): ?SchoolClass + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM school_classes WHERE id = :id', + ['id' => (string) $id], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findByName( + ClassName $name, + TenantId $tenantId, + AcademicYearId $academicYearId, + ): ?SchoolClass { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM school_classes + WHERE tenant_id = :tenant_id + AND academic_year_id = :academic_year_id + AND name = :name + AND deleted_at IS NULL', + [ + 'tenant_id' => (string) $tenantId, + 'academic_year_id' => (string) $academicYearId, + 'name' => (string) $name, + ], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findActiveByTenantAndYear( + TenantId $tenantId, + AcademicYearId $academicYearId, + ): array { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM school_classes + WHERE tenant_id = :tenant_id + AND academic_year_id = :academic_year_id + AND status = :status + ORDER BY name ASC', + [ + 'tenant_id' => (string) $tenantId, + 'academic_year_id' => (string) $academicYearId, + 'status' => ClassStatus::ACTIVE->value, + ], + ); + + return array_map(fn ($row) => $this->hydrate($row), $rows); + } + + #[Override] + public function delete(ClassId $id): void + { + $this->connection->delete('school_classes', ['id' => (string) $id]); + } + + /** + * @param array $row + */ + private function hydrate(array $row): SchoolClass + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $schoolId */ + $schoolId = $row['school_id']; + /** @var string $academicYearId */ + $academicYearId = $row['academic_year_id']; + /** @var string $name */ + $name = $row['name']; + /** @var string|null $level */ + $level = $row['level']; + /** @var int|string|null $capacity */ + $capacity = $row['capacity']; + /** @var string $status */ + $status = $row['status']; + /** @var string|null $description */ + $description = $row['description']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + /** @var string|null $deletedAt */ + $deletedAt = $row['deleted_at']; + + return SchoolClass::reconstitute( + id: ClassId::fromString($id), + tenantId: TenantId::fromString($tenantId), + schoolId: SchoolId::fromString($schoolId), + academicYearId: AcademicYearId::fromString($academicYearId), + name: new ClassName($name), + level: $level !== null ? SchoolLevel::from($level) : null, + capacity: $capacity !== null ? (int) $capacity : null, + status: ClassStatus::from($status), + description: $description, + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + deletedAt: $deletedAt !== null ? new DateTimeImmutable($deletedAt) : null, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepository.php new file mode 100644 index 0000000..f534132 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepository.php @@ -0,0 +1,100 @@ + Indexed by ID */ + private array $byId = []; + + /** @var array Indexed by tenant:year:name */ + private array $byTenantYearName = []; + + #[Override] + public function save(SchoolClass $class): void + { + // If class already exists, remove the old name key (handles renames) + $existingClass = $this->byId[(string) $class->id] ?? null; + if ($existingClass !== null) { + $oldKey = $this->nameKey($existingClass->name, $existingClass->tenantId, $existingClass->academicYearId); + unset($this->byTenantYearName[$oldKey]); + } + + $this->byId[(string) $class->id] = $class; + $this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)] = $class; + } + + #[Override] + public function get(ClassId $id): SchoolClass + { + $class = $this->findById($id); + + if ($class === null) { + throw ClasseNotFoundException::withId($id); + } + + return $class; + } + + #[Override] + public function findById(ClassId $id): ?SchoolClass + { + return $this->byId[(string) $id] ?? null; + } + + #[Override] + public function findByName( + ClassName $name, + TenantId $tenantId, + AcademicYearId $academicYearId, + ): ?SchoolClass { + return $this->byTenantYearName[$this->nameKey($name, $tenantId, $academicYearId)] ?? null; + } + + #[Override] + public function findActiveByTenantAndYear( + TenantId $tenantId, + AcademicYearId $academicYearId, + ): array { + $result = []; + + foreach ($this->byId as $class) { + if ($class->tenantId->equals($tenantId) + && $class->academicYearId->equals($academicYearId) + && $class->status === ClassStatus::ACTIVE + ) { + $result[] = $class; + } + } + + return $result; + } + + #[Override] + public function delete(ClassId $id): void + { + $class = $this->byId[(string) $id] ?? null; + + if ($class !== null) { + unset($this->byId[(string) $id]); + unset($this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)]); + } + } + + private function nameKey(ClassName $name, TenantId $tenantId, AcademicYearId $academicYearId): string + { + return $tenantId . ':' . $academicYearId . ':' . mb_strtolower((string) $name, 'UTF-8'); + } +} diff --git a/backend/src/Administration/Infrastructure/Security/ClassVoter.php b/backend/src/Administration/Infrastructure/Security/ClassVoter.php new file mode 100644 index 0000000..4276bfd --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/ClassVoter.php @@ -0,0 +1,146 @@ + + */ +final class ClassVoter extends Voter +{ + public const string VIEW = 'CLASS_VIEW'; + public const string CREATE = 'CLASS_CREATE'; + public const string EDIT = 'CLASS_EDIT'; + public const string DELETE = 'CLASS_DELETE'; + + private const array SUPPORTED_ATTRIBUTES = [ + self::VIEW, + self::CREATE, + self::EDIT, + self::DELETE, + ]; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) { + return false; + } + + // CREATE and VIEW (for collections) don't require a subject + if ($attribute === self::CREATE || ($attribute === self::VIEW && $subject === null)) { + return true; + } + + return $subject instanceof SchoolClass || $subject instanceof ClassResource; + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof UserInterface) { + return false; + } + + // Récupérer le rôle depuis les rôles Symfony + $roles = $user->getRoles(); + + return match ($attribute) { + self::VIEW => $this->canView($roles), + self::CREATE => $this->canCreate($roles), + self::EDIT => $this->canEdit($roles), + self::DELETE => $this->canDelete($roles), + default => false, + }; + } + + /** + * @param string[] $roles + */ + private function canView(array $roles): bool + { + // Personnel de l'établissement uniquement + // ELEVE et PARENT sont exclus car ils ne doivent voir que leur propre classe + // via un endpoint dédié (non implémenté - nécessite le module Affectations) + 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 canCreate(array $roles): bool + { + // Seuls ADMIN et SUPER_ADMIN peuvent créer des classes + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + ]); + } + + /** + * @param string[] $roles + */ + private function canEdit(array $roles): bool + { + // Seuls ADMIN et SUPER_ADMIN peuvent modifier des classes + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + ]); + } + + /** + * @param string[] $roles + */ + private function canDelete(array $roles): bool + { + // Seuls ADMIN et SUPER_ADMIN peuvent supprimer des classes + 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/tests/Unit/Administration/Application/Command/ArchiveClass/ArchiveClassHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ArchiveClass/ArchiveClassHandlerTest.php new file mode 100644 index 0000000..c5d09a8 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ArchiveClass/ArchiveClassHandlerTest.php @@ -0,0 +1,135 @@ +classRepository = new InMemoryClassRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-01 10:00:00'); + } + }; + } + + #[Test] + public function itArchivesEmptyClass(): void + { + $class = $this->createAndSaveClass(); + $queryBus = $this->createQueryBusReturning(0); + $handler = new ArchiveClassHandler($this->classRepository, $queryBus, $this->clock); + + $command = new ArchiveClassCommand(classId: (string) $class->id); + + $handler($command); + + $archivedClass = $this->classRepository->get($class->id); + self::assertSame(ClassStatus::ARCHIVED, $archivedClass->status); + self::assertNotNull($archivedClass->deletedAt); + } + + #[Test] + public function itThrowsExceptionWhenStudentsAreAffected(): void + { + $class = $this->createAndSaveClass(); + $queryBus = $this->createQueryBusReturning(5); + $handler = new ArchiveClassHandler($this->classRepository, $queryBus, $this->clock); + + $command = new ArchiveClassCommand(classId: (string) $class->id); + + $this->expectException(ClasseNonSupprimableException::class); + $this->expectExceptionMessage('5 élève(s)'); + + $handler($command); + } + + #[Test] + public function itDoesNotModifyStatusWhenAlreadyArchived(): void + { + $class = $this->createAndSaveClass(); + $archiveTime = new DateTimeImmutable('2026-01-20 10:00:00'); + $class->archiver($archiveTime); + $this->classRepository->save($class); + + $queryBus = $this->createQueryBusReturning(0); + $handler = new ArchiveClassHandler($this->classRepository, $queryBus, $this->clock); + + $command = new ArchiveClassCommand(classId: (string) $class->id); + + $handler($command); + + $archivedClass = $this->classRepository->get($class->id); + // deletedAt should not have changed + self::assertEquals($archiveTime, $archivedClass->deletedAt); + } + + private function createAndSaveClass(): SchoolClass + { + $class = SchoolClass::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('6ème A'), + level: SchoolLevel::SIXIEME, + capacity: 30, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + + $this->classRepository->save($class); + + return $class; + } + + private function createQueryBusReturning(int $studentCount): MessageBusInterface + { + return new class($studentCount) implements MessageBusInterface { + public function __construct(private readonly int $studentCount) + { + } + + public function dispatch(object $message, array $stamps = []): Envelope + { + if (!$message instanceof HasStudentsInClassQuery) { + throw new RuntimeException('Unexpected message type'); + } + + $envelope = new Envelope($message); + + return $envelope->with(new HandledStamp($this->studentCount, 'handler')); + } + }; + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/CreateClass/CreateClassHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/CreateClass/CreateClassHandlerTest.php new file mode 100644 index 0000000..6bb93dc --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/CreateClass/CreateClassHandlerTest.php @@ -0,0 +1,192 @@ +classRepository = new InMemoryClassRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-01-31 10:00:00'); + } + }; + } + + #[Test] + public function itCreatesClassSuccessfully(): void + { + $handler = new CreateClassHandler($this->classRepository, $this->clock); + $command = new CreateClassCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + name: '6ème A', + level: SchoolLevel::SIXIEME->value, + capacity: 30, + ); + + $class = $handler($command); + + self::assertNotEmpty((string) $class->id); + self::assertSame('6ème A', (string) $class->name); + self::assertSame(SchoolLevel::SIXIEME, $class->level); + self::assertSame(30, $class->capacity); + } + + #[Test] + public function itCreatesClassWithNullLevelAndCapacity(): void + { + $handler = new CreateClassHandler($this->classRepository, $this->clock); + $command = new CreateClassCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + name: 'Classe spéciale', + level: null, + capacity: null, + ); + + $class = $handler($command); + + self::assertNotEmpty((string) $class->id); + self::assertSame('Classe spéciale', (string) $class->name); + self::assertNull($class->level); + self::assertNull($class->capacity); + } + + #[Test] + public function itPersistsClassInRepository(): void + { + $handler = new CreateClassHandler($this->classRepository, $this->clock); + $command = new CreateClassCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + name: '6ème A', + level: SchoolLevel::SIXIEME->value, + capacity: 30, + ); + + $createdClass = $handler($command); + + $class = $this->classRepository->get( + ClassId::fromString((string) $createdClass->id), + ); + + self::assertSame('6ème A', (string) $class->name); + self::assertSame(ClassStatus::ACTIVE, $class->status); + self::assertSame(SchoolLevel::SIXIEME, $class->level); + self::assertSame(30, $class->capacity); + } + + #[Test] + public function itThrowsExceptionWhenClassNameAlreadyExists(): void + { + $handler = new CreateClassHandler($this->classRepository, $this->clock); + $command = new CreateClassCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + name: '6ème A', + level: SchoolLevel::SIXIEME->value, + capacity: 30, + ); + + // First creation should succeed + $handler($command); + + // Second creation with same name should throw + $this->expectException(ClasseDejaExistanteException::class); + $this->expectExceptionMessage('Une classe avec le nom "6ème A" existe déjà pour cette année scolaire.'); + + $handler($command); + } + + #[Test] + public function itAllowsSameNameInDifferentTenant(): void + { + $handler = new CreateClassHandler($this->classRepository, $this->clock); + + // Create in tenant 1 + $command1 = new CreateClassCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + name: '6ème A', + level: SchoolLevel::SIXIEME->value, + capacity: 30, + ); + $class1 = $handler($command1); + + // Create same name in tenant 2 should succeed + $command2 = new CreateClassCommand( + tenantId: '550e8400-e29b-41d4-a716-446655440099', + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + name: '6ème A', + level: SchoolLevel::SIXIEME->value, + capacity: 30, + ); + $class2 = $handler($command2); + + self::assertFalse($class1->id->equals($class2->id)); + self::assertSame('6ème A', (string) $class1->name); + self::assertSame('6ème A', (string) $class2->name); + } + + #[Test] + public function itAllowsSameNameInDifferentAcademicYear(): void + { + $handler = new CreateClassHandler($this->classRepository, $this->clock); + + // Create in year 1 + $command1 = new CreateClassCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + name: '6ème A', + level: SchoolLevel::SIXIEME->value, + capacity: 30, + ); + $class1 = $handler($command1); + + // Create same name in year 2 should succeed + $command2 = new CreateClassCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: '550e8400-e29b-41d4-a716-446655440099', + name: '6ème A', + level: SchoolLevel::SIXIEME->value, + capacity: 30, + ); + $class2 = $handler($command2); + + self::assertFalse($class1->id->equals($class2->id)); + self::assertSame('6ème A', (string) $class1->name); + self::assertSame('6ème A', (string) $class2->name); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/UpdateClass/UpdateClassHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/UpdateClass/UpdateClassHandlerTest.php new file mode 100644 index 0000000..d5c956f --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/UpdateClass/UpdateClassHandlerTest.php @@ -0,0 +1,216 @@ +classRepository = new InMemoryClassRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-01 10:00:00'); + } + }; + } + + #[Test] + public function itUpdatesClassName(): void + { + $class = $this->createAndSaveClass(); + $handler = new UpdateClassHandler($this->classRepository, $this->clock); + + $command = new UpdateClassCommand( + classId: (string) $class->id, + name: '6ème B', + ); + + $handler($command); + + $updatedClass = $this->classRepository->get($class->id); + self::assertSame('6ème B', (string) $updatedClass->name); + } + + #[Test] + public function itUpdatesClassLevel(): void + { + $class = $this->createAndSaveClass(); + $handler = new UpdateClassHandler($this->classRepository, $this->clock); + + $command = new UpdateClassCommand( + classId: (string) $class->id, + level: SchoolLevel::CINQUIEME->value, + ); + + $handler($command); + + $updatedClass = $this->classRepository->get($class->id); + self::assertSame(SchoolLevel::CINQUIEME, $updatedClass->level); + } + + #[Test] + public function itClearsClassLevel(): void + { + $class = $this->createAndSaveClass(); + $handler = new UpdateClassHandler($this->classRepository, $this->clock); + + $command = new UpdateClassCommand( + classId: (string) $class->id, + clearLevel: true, + ); + + $handler($command); + + $updatedClass = $this->classRepository->get($class->id); + self::assertNull($updatedClass->level); + } + + #[Test] + public function itUpdatesClassCapacity(): void + { + $class = $this->createAndSaveClass(); + $handler = new UpdateClassHandler($this->classRepository, $this->clock); + + $command = new UpdateClassCommand( + classId: (string) $class->id, + capacity: 35, + ); + + $handler($command); + + $updatedClass = $this->classRepository->get($class->id); + self::assertSame(35, $updatedClass->capacity); + } + + #[Test] + public function itUpdatesClassDescription(): void + { + $class = $this->createAndSaveClass(); + $handler = new UpdateClassHandler($this->classRepository, $this->clock); + + $command = new UpdateClassCommand( + classId: (string) $class->id, + description: 'Classe option musique', + ); + + $handler($command); + + $updatedClass = $this->classRepository->get($class->id); + self::assertSame('Classe option musique', $updatedClass->description); + } + + #[Test] + public function itUpdatesMultipleFields(): void + { + $class = $this->createAndSaveClass(); + $handler = new UpdateClassHandler($this->classRepository, $this->clock); + + $command = new UpdateClassCommand( + classId: (string) $class->id, + name: '5ème C', + level: SchoolLevel::CINQUIEME->value, + capacity: 28, + description: 'Section européenne', + ); + + $handler($command); + + $updatedClass = $this->classRepository->get($class->id); + self::assertSame('5ème C', (string) $updatedClass->name); + self::assertSame(SchoolLevel::CINQUIEME, $updatedClass->level); + self::assertSame(28, $updatedClass->capacity); + self::assertSame('Section européenne', $updatedClass->description); + } + + #[Test] + public function itThrowsExceptionWhenRenamingToExistingName(): void + { + // Create first class + $class1 = $this->createAndSaveClass(); + + // Create second class with different name + $class2 = SchoolClass::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('6ème B'), + level: SchoolLevel::SIXIEME, + capacity: 30, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + $this->classRepository->save($class2); + + $handler = new UpdateClassHandler($this->classRepository, $this->clock); + + // Try to rename class2 to class1's name + $command = new UpdateClassCommand( + classId: (string) $class2->id, + name: '6ème A', + ); + + $this->expectException(ClasseDejaExistanteException::class); + $this->expectExceptionMessage('Une classe avec le nom "6ème A" existe déjà pour cette année scolaire.'); + + $handler($command); + } + + #[Test] + public function itAllowsRenamingToSameName(): void + { + $class = $this->createAndSaveClass(); + $handler = new UpdateClassHandler($this->classRepository, $this->clock); + + // Renaming to the same name should work + $command = new UpdateClassCommand( + classId: (string) $class->id, + name: '6ème A', + ); + + $handler($command); + + $updatedClass = $this->classRepository->get($class->id); + self::assertSame('6ème A', (string) $updatedClass->name); + } + + private function createAndSaveClass(): SchoolClass + { + $class = SchoolClass::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('6ème A'), + level: SchoolLevel::SIXIEME, + capacity: 30, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + + $this->classRepository->save($class); + + return $class; + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolClass/ClassNameTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolClass/ClassNameTest.php new file mode 100644 index 0000000..df84ab1 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolClass/ClassNameTest.php @@ -0,0 +1,104 @@ +value); + self::assertSame('6ème A', (string) $name); + } + + #[Test] + public function constructTrimsWhitespace(): void + { + $name = new ClassName(' 6ème A '); + + self::assertSame('6ème A', $name->value); + } + + #[Test] + #[DataProvider('validNamesProvider')] + public function constructAcceptsValidNames(string $value): void + { + $name = new ClassName($value); + + self::assertNotEmpty($name->value); + } + + /** + * @return array + */ + public static function validNamesProvider(): array + { + return [ + 'minimum length' => ['AB'], + 'typical class name' => ['6ème A'], + 'longer name' => ['Classe préparatoire aux grandes écoles'], + 'with numbers' => ['CM1-2'], + 'maximum length' => [str_repeat('A', 50)], + ]; + } + + #[Test] + #[DataProvider('invalidNamesProvider')] + public function constructRejectsInvalidNames(string $value): void + { + $this->expectException(ClassNameInvalideException::class); + + new ClassName($value); + } + + /** + * @return array + */ + public static function invalidNamesProvider(): array + { + return [ + 'empty string' => [''], + 'single character' => ['A'], + 'only whitespace' => [' '], + 'one char after trim' => [' A '], + 'too long' => [str_repeat('A', 51)], + ]; + } + + #[Test] + public function equalsReturnsTrueForSameValue(): void + { + $name1 = new ClassName('6ème A'); + $name2 = new ClassName('6ème A'); + + self::assertTrue($name1->equals($name2)); + } + + #[Test] + public function equalsReturnsFalseForDifferentValue(): void + { + $name1 = new ClassName('6ème A'); + $name2 = new ClassName('6ème B'); + + self::assertFalse($name1->equals($name2)); + } + + #[Test] + public function equalsIsCaseSensitive(): void + { + $name1 = new ClassName('Classe A'); + $name2 = new ClassName('classe A'); + + self::assertFalse($name1->equals($name2)); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolClass/SchoolClassTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolClass/SchoolClassTest.php new file mode 100644 index 0000000..8f323a4 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolClass/SchoolClassTest.php @@ -0,0 +1,285 @@ +createClass(); + + self::assertSame(ClassStatus::ACTIVE, $class->status); + self::assertTrue($class->estActive()); + self::assertTrue($class->peutRecevoirEleves()); + } + + #[Test] + public function creerRecordsClasseCreeeEvent(): void + { + $class = $this->createClass(); + + $events = $class->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(ClasseCreee::class, $events[0]); + self::assertSame($class->id, $events[0]->classId); + self::assertSame($class->tenantId, $events[0]->tenantId); + self::assertSame($class->name, $events[0]->name); + } + + #[Test] + public function creerSetsAllProperties(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $schoolId = SchoolId::fromString(self::SCHOOL_ID); + $academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID); + $name = new ClassName('6ème A'); + $level = SchoolLevel::SIXIEME; + $capacity = 30; + $createdAt = new DateTimeImmutable('2026-01-15 10:00:00'); + + $class = SchoolClass::creer( + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + name: $name, + level: $level, + capacity: $capacity, + createdAt: $createdAt, + ); + + self::assertTrue($class->tenantId->equals($tenantId)); + self::assertTrue($class->schoolId->equals($schoolId)); + self::assertTrue($class->academicYearId->equals($academicYearId)); + self::assertTrue($class->name->equals($name)); + self::assertSame($level, $class->level); + self::assertSame($capacity, $class->capacity); + self::assertEquals($createdAt, $class->createdAt); + self::assertEquals($createdAt, $class->updatedAt); + self::assertNull($class->deletedAt); + self::assertNull($class->description); + } + + #[Test] + public function creerWithNullLevelAndCapacity(): void + { + $class = SchoolClass::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('Classe spéciale'), + level: null, + capacity: null, + createdAt: new DateTimeImmutable(), + ); + + self::assertNull($class->level); + self::assertNull($class->capacity); + } + + #[Test] + public function renommerChangesNameAndRecordsEvent(): void + { + $class = $this->createClass(); + $class->pullDomainEvents(); + $ancienNom = $class->name; + $nouveauNom = new ClassName('6ème B'); + $at = new DateTimeImmutable('2026-02-01 10:00:00'); + + $class->renommer($nouveauNom, $at); + + self::assertTrue($class->name->equals($nouveauNom)); + self::assertEquals($at, $class->updatedAt); + + $events = $class->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(ClasseModifiee::class, $events[0]); + self::assertTrue($events[0]->ancienNom->equals($ancienNom)); + self::assertTrue($events[0]->nouveauNom->equals($nouveauNom)); + } + + #[Test] + public function renommerWithSameNameDoesNothing(): void + { + $class = $this->createClass(); + $class->pullDomainEvents(); + $originalUpdatedAt = $class->updatedAt; + + $class->renommer(new ClassName('6ème A'), new DateTimeImmutable('2026-02-01 10:00:00')); + + self::assertEquals($originalUpdatedAt, $class->updatedAt); + self::assertEmpty($class->pullDomainEvents()); + } + + #[Test] + public function changerNiveauUpdatesLevel(): void + { + $class = $this->createClass(); + $at = new DateTimeImmutable('2026-02-01 10:00:00'); + + $class->changerNiveau(SchoolLevel::CINQUIEME, $at); + + self::assertSame(SchoolLevel::CINQUIEME, $class->level); + self::assertEquals($at, $class->updatedAt); + } + + #[Test] + public function changerNiveauWithSameLevelDoesNothing(): void + { + $class = $this->createClass(); + $originalUpdatedAt = $class->updatedAt; + + $class->changerNiveau(SchoolLevel::SIXIEME, new DateTimeImmutable('2026-02-01 10:00:00')); + + self::assertEquals($originalUpdatedAt, $class->updatedAt); + } + + #[Test] + public function changerCapaciteUpdatesCapacity(): void + { + $class = $this->createClass(); + $at = new DateTimeImmutable('2026-02-01 10:00:00'); + + $class->changerCapacite(35, $at); + + self::assertSame(35, $class->capacity); + self::assertEquals($at, $class->updatedAt); + } + + #[Test] + public function changerCapaciteWithSameValueDoesNothing(): void + { + $class = $this->createClass(); + $originalUpdatedAt = $class->updatedAt; + + $class->changerCapacite(30, new DateTimeImmutable('2026-02-01 10:00:00')); + + self::assertEquals($originalUpdatedAt, $class->updatedAt); + } + + #[Test] + public function decrireUpdatesDescription(): void + { + $class = $this->createClass(); + $at = new DateTimeImmutable('2026-02-01 10:00:00'); + + $class->decrire('Classe option musique', $at); + + self::assertSame('Classe option musique', $class->description); + self::assertEquals($at, $class->updatedAt); + } + + #[Test] + public function archiverChangesStatusAndRecordsEvent(): void + { + $class = $this->createClass(); + $class->pullDomainEvents(); + $at = new DateTimeImmutable('2026-02-01 10:00:00'); + + $class->archiver($at); + + self::assertSame(ClassStatus::ARCHIVED, $class->status); + self::assertFalse($class->estActive()); + self::assertFalse($class->peutRecevoirEleves()); + self::assertEquals($at, $class->deletedAt); + self::assertEquals($at, $class->updatedAt); + + $events = $class->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(ClasseArchivee::class, $events[0]); + } + + #[Test] + public function archiverAlreadyArchivedClassDoesNothing(): void + { + $class = $this->createClass(); + $class->archiver(new DateTimeImmutable('2026-02-01 10:00:00')); + $class->pullDomainEvents(); + $originalDeletedAt = $class->deletedAt; + + $class->archiver(new DateTimeImmutable('2026-02-02 10:00:00')); + + self::assertEquals($originalDeletedAt, $class->deletedAt); + self::assertEmpty($class->pullDomainEvents()); + } + + #[Test] + public function reconstituteRestoresAllProperties(): void + { + $id = \App\Administration\Domain\Model\SchoolClass\ClassId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $schoolId = SchoolId::fromString(self::SCHOOL_ID); + $academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID); + $name = new ClassName('6ème A'); + $level = SchoolLevel::SIXIEME; + $capacity = 30; + $status = ClassStatus::ARCHIVED; + $description = 'Test description'; + $createdAt = new DateTimeImmutable('2026-01-15 10:00:00'); + $updatedAt = new DateTimeImmutable('2026-02-01 10:00:00'); + $deletedAt = new DateTimeImmutable('2026-02-01 10:00:00'); + + $class = SchoolClass::reconstitute( + id: $id, + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + name: $name, + level: $level, + capacity: $capacity, + status: $status, + description: $description, + createdAt: $createdAt, + updatedAt: $updatedAt, + deletedAt: $deletedAt, + ); + + self::assertTrue($class->id->equals($id)); + self::assertTrue($class->tenantId->equals($tenantId)); + self::assertTrue($class->schoolId->equals($schoolId)); + self::assertTrue($class->academicYearId->equals($academicYearId)); + self::assertTrue($class->name->equals($name)); + self::assertSame($level, $class->level); + self::assertSame($capacity, $class->capacity); + self::assertSame($status, $class->status); + self::assertSame($description, $class->description); + self::assertEquals($createdAt, $class->createdAt); + self::assertEquals($updatedAt, $class->updatedAt); + self::assertEquals($deletedAt, $class->deletedAt); + self::assertEmpty($class->pullDomainEvents()); + } + + private function createClass(): SchoolClass + { + return SchoolClass::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('6ème A'), + level: SchoolLevel::SIXIEME, + capacity: 30, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/SchoolClass/SchoolLevelTest.php b/backend/tests/Unit/Administration/Domain/Model/SchoolClass/SchoolLevelTest.php new file mode 100644 index 0000000..252d83e --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/SchoolClass/SchoolLevelTest.php @@ -0,0 +1,98 @@ +estPrimaire()); + self::assertFalse($level->estCollege()); + self::assertFalse($level->estLycee()); + self::assertSame('Primaire', $level->cycle()); + } + + /** + * @return array + */ + public static function primaryLevelsProvider(): array + { + return [ + 'CP' => [SchoolLevel::CP], + 'CE1' => [SchoolLevel::CE1], + 'CE2' => [SchoolLevel::CE2], + 'CM1' => [SchoolLevel::CM1], + 'CM2' => [SchoolLevel::CM2], + ]; + } + + #[Test] + #[DataProvider('collegeLevelsProvider')] + public function estCollegeReturnsTrueForCollegeLevels(SchoolLevel $level): void + { + self::assertFalse($level->estPrimaire()); + self::assertTrue($level->estCollege()); + self::assertFalse($level->estLycee()); + self::assertSame('Collège', $level->cycle()); + } + + /** + * @return array + */ + public static function collegeLevelsProvider(): array + { + return [ + '6ème' => [SchoolLevel::SIXIEME], + '5ème' => [SchoolLevel::CINQUIEME], + '4ème' => [SchoolLevel::QUATRIEME], + '3ème' => [SchoolLevel::TROISIEME], + ]; + } + + #[Test] + #[DataProvider('lyceeLevelsProvider')] + public function estLyceeReturnsTrueForLyceeLevels(SchoolLevel $level): void + { + self::assertFalse($level->estPrimaire()); + self::assertFalse($level->estCollege()); + self::assertTrue($level->estLycee()); + self::assertSame('Lycée', $level->cycle()); + } + + /** + * @return array + */ + public static function lyceeLevelsProvider(): array + { + return [ + '2nde' => [SchoolLevel::SECONDE], + '1ère' => [SchoolLevel::PREMIERE], + 'Terminale' => [SchoolLevel::TERMINALE], + ]; + } + + #[Test] + public function labelReturnsValue(): void + { + self::assertSame('6ème', SchoolLevel::SIXIEME->label()); + self::assertSame('CM2', SchoolLevel::CM2->label()); + self::assertSame('Terminale', SchoolLevel::TERMINALE->label()); + } + + #[Test] + public function valueMatchesExpectedString(): void + { + self::assertSame('CP', SchoolLevel::CP->value); + self::assertSame('6ème', SchoolLevel::SIXIEME->value); + self::assertSame('2nde', SchoolLevel::SECONDE->value); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepositoryTest.php b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepositoryTest.php new file mode 100644 index 0000000..b6410d0 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepositoryTest.php @@ -0,0 +1,173 @@ +repository = new InMemoryClassRepository(); + } + + #[Test] + public function saveAndGet(): void + { + $class = $this->createClass('6ème A'); + + $this->repository->save($class); + + $retrieved = $this->repository->get($class->id); + self::assertTrue($class->id->equals($retrieved->id)); + self::assertTrue($class->name->equals($retrieved->name)); + } + + #[Test] + public function getThrowsExceptionForUnknownId(): void + { + $this->expectException(ClasseNotFoundException::class); + + $this->repository->get(ClassId::generate()); + } + + #[Test] + public function findByIdReturnsNullForUnknownId(): void + { + $result = $this->repository->findById(ClassId::generate()); + + self::assertNull($result); + } + + #[Test] + public function findByName(): void + { + $class = $this->createClass('6ème A'); + $this->repository->save($class); + + $found = $this->repository->findByName( + new ClassName('6ème A'), + TenantId::fromString(self::TENANT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + self::assertNotNull($found); + self::assertTrue($class->id->equals($found->id)); + } + + #[Test] + public function findByNameReturnsNullForUnknownName(): void + { + $class = $this->createClass('6ème A'); + $this->repository->save($class); + + $found = $this->repository->findByName( + new ClassName('6ème B'), + TenantId::fromString(self::TENANT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + self::assertNull($found); + } + + #[Test] + public function findByNameIsCaseInsensitive(): void + { + $class = $this->createClass('6ème A'); + $this->repository->save($class); + + $found = $this->repository->findByName( + new ClassName('6ÈME A'), + TenantId::fromString(self::TENANT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + self::assertNotNull($found); + } + + #[Test] + public function findActiveByTenantAndYear(): void + { + $class1 = $this->createClass('6ème A'); + $class2 = $this->createClass('6ème B'); + $class3 = $this->createClass('6ème C'); + $class3->archiver(new DateTimeImmutable()); + + $this->repository->save($class1); + $this->repository->save($class2); + $this->repository->save($class3); + + $activeClasses = $this->repository->findActiveByTenantAndYear( + TenantId::fromString(self::TENANT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + self::assertCount(2, $activeClasses); + } + + #[Test] + public function findActiveByTenantAndYearReturnsEmptyArrayForDifferentTenant(): void + { + $class = $this->createClass('6ème A'); + $this->repository->save($class); + + $activeClasses = $this->repository->findActiveByTenantAndYear( + TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + self::assertEmpty($activeClasses); + } + + #[Test] + public function delete(): void + { + $class = $this->createClass('6ème A'); + $this->repository->save($class); + + $this->repository->delete($class->id); + + self::assertNull($this->repository->findById($class->id)); + } + + #[Test] + public function deleteNonExistentClassDoesNotThrow(): void + { + // Should not throw + $this->repository->delete(ClassId::generate()); + + $this->expectNotToPerformAssertions(); + } + + private function createClass(string $name): SchoolClass + { + return SchoolClass::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName($name), + level: SchoolLevel::SIXIEME, + capacity: 30, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } +} diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts new file mode 100644 index 0000000..b924f09 --- /dev/null +++ b/frontend/e2e/classes.spec.ts @@ -0,0 +1,465 @@ +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); + +// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts) +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}`; + +// Test credentials +const ADMIN_EMAIL = 'e2e-classes-admin@example.com'; +const ADMIN_PASSWORD = 'ClassesTest123'; + +// Force serial execution to ensure Empty State runs first +test.describe.configure({ mode: 'serial' }); + +test.describe('Classes Management (Story 2.1)', () => { + // Create admin user and clean up classes before running tests + 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('Classes E2E test admin user created'); + + // Clean up all classes for this tenant to ensure Empty State test works + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, + { encoding: 'utf-8' } + ); + console.log('Classes cleaned up for E2E tests'); + } catch (error) { + console.error('Setup error:', error); + } + }); + + // Helper to login as admin + 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 }); + } + + // Helper to open "Nouvelle classe" dialog with proper wait + async function openNewClassDialog(page: import('@playwright/test').Page) { + const button = page.getByRole('button', { name: /nouvelle classe/i }); + await button.waitFor({ state: 'visible' }); + + // Wait for any pending network requests to finish before clicking + await page.waitForLoadState('networkidle'); + + // Click the button + await button.click(); + + // Wait for dialog to appear - retry click if needed (webkit timing issue) + const dialog = page.getByRole('dialog'); + try { + await expect(dialog).toBeVisible({ timeout: 5000 }); + } catch { + // Retry once - webkit sometimes needs a second click + await button.click(); + await expect(dialog).toBeVisible({ timeout: 10000 }); + } + } + + // ============================================================================ + // EMPTY STATE - Must run FIRST before any class is created + // ============================================================================ + test.describe('Empty State', () => { + test('shows empty state message when no classes exist', async ({ page }) => { + // Clean up classes right before this specific test to avoid race conditions with parallel browsers + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Ignore cleanup errors + } + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Wait for page to load + await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible(); + + // Should show empty state + await expect(page.locator('.empty-state')).toBeVisible(); + await expect(page.getByText(/aucune classe/i)).toBeVisible(); + await expect(page.getByRole('button', { name: /créer une classe/i })).toBeVisible(); + }); + }); + + // ============================================================================ + // List Display + // ============================================================================ + test.describe('List Display', () => { + test('displays all created classes in the list', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create multiple classes + const classNames = [ + `Liste-6emeA-${Date.now()}`, + `Liste-6emeB-${Date.now()}`, + `Liste-5emeA-${Date.now()}`, + ]; + + for (const name of classNames) { + await openNewClassDialog(page); + await page.locator('#class-name').fill(name); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + } + + // Verify ALL classes appear in the list + for (const name of classNames) { + await expect(page.getByText(name)).toBeVisible(); + } + + // Verify the number of class cards matches (at least the ones we created) + const classCards = page.locator('.class-card'); + const count = await classCards.count(); + expect(count).toBeGreaterThanOrEqual(classNames.length); + }); + + test('displays class details correctly (level, capacity)', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a class with all details + const className = `Details-${Date.now()}`; + await openNewClassDialog(page); + await page.locator('#class-name').fill(className); + await page.locator('#class-level').selectOption('CM2'); + await page.locator('#class-capacity').fill('25'); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Find the class card + const classCard = page.locator('.class-card', { hasText: className }); + await expect(classCard).toBeVisible(); + + // Verify details are displayed + await expect(classCard.getByText('CM2')).toBeVisible(); + await expect(classCard.getByText('25 places')).toBeVisible(); + await expect(classCard.getByText('Active')).toBeVisible(); + }); + }); + + // ============================================================================ + // AC1: Class Creation + // ============================================================================ + test.describe('AC1: Class Creation', () => { + test('can create a new class with all fields', async ({ page }) => { + await loginAsAdmin(page); + + // Navigate to classes page + await page.goto(`${ALPHA_URL}/admin/classes`); + await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible(); + + // Click "Nouvelle classe" button + await openNewClassDialog(page); + await expect(page.getByRole('heading', { name: /nouvelle classe/i })).toBeVisible(); + + // Fill form + const uniqueName = `Test-E2E-${Date.now()}`; + await page.locator('#class-name').fill(uniqueName); + await page.locator('#class-level').selectOption('6ème'); + await page.locator('#class-capacity').fill('30'); + + // Submit + await page.getByRole('button', { name: /créer la classe/i }).click(); + + // Modal should close and class should appear in list + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 }); + }); + + test('can create a class with only required fields (name)', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + await openNewClassDialog(page); + + // Fill only the name (required) + const uniqueName = `Minimal-${Date.now()}`; + await page.locator('#class-name').fill(uniqueName); + + // Submit button should be enabled + const submitButton = page.getByRole('button', { name: /créer la classe/i }); + await expect(submitButton).toBeEnabled(); + + await submitButton.click(); + + // Class should be created + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 }); + }); + + test('submit button is disabled when name is empty', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + await openNewClassDialog(page); + + // Don't fill the name + const submitButton = page.getByRole('button', { name: /créer la classe/i }); + await expect(submitButton).toBeDisabled(); + + // Fill level and capacity but not name + await page.locator('#class-level').selectOption('CE1'); + await page.locator('#class-capacity').fill('25'); + await expect(submitButton).toBeDisabled(); + + // Fill name - button should enable + await page.locator('#class-name').fill('Test'); + await expect(submitButton).toBeEnabled(); + }); + + test('can cancel class creation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + await openNewClassDialog(page); + + // Fill form + await page.locator('#class-name').fill('Should-Not-Be-Created'); + + // Click cancel + await page.getByRole('button', { name: /annuler/i }).click(); + + // Modal should close + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Class should not appear in list + await expect(page.getByText('Should-Not-Be-Created')).not.toBeVisible(); + }); + }); + + // ============================================================================ + // AC2: Class Modification + // ============================================================================ + test.describe('AC2: Class Modification', () => { + test('can modify an existing class', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // First create a class to modify + await openNewClassDialog(page); + const originalName = `ToModify-${Date.now()}`; + await page.locator('#class-name').fill(originalName); + await page.locator('#class-level').selectOption('CM1'); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Find the class card and click modify + const classCard = page.locator('.class-card', { hasText: originalName }); + await classCard.getByRole('button', { name: /modifier/i }).click(); + + // Should navigate to edit page + await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/); + await expect(page.getByRole('heading', { name: /modifier la classe/i })).toBeVisible(); + + // Modify the name + const newName = `Modified-${Date.now()}`; + await page.locator('#class-name').fill(newName); + await page.locator('#class-level').selectOption('CM2'); + await page.locator('#class-capacity').fill('28'); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + + // Should show success message + await expect(page.getByText(/modifiée avec succès/i)).toBeVisible({ timeout: 10000 }); + + // Go back to list and verify + await page.goto(`${ALPHA_URL}/admin/classes`); + await expect(page.getByText(newName)).toBeVisible(); + }); + + test('can cancel modification', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a class + await openNewClassDialog(page); + const originalName = `NoChange-${Date.now()}`; + await page.locator('#class-name').fill(originalName); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Click modify + const classCard = page.locator('.class-card', { hasText: originalName }); + await classCard.getByRole('button', { name: /modifier/i }).click(); + + // Modify but cancel + await page.locator('#class-name').fill('Should-Not-Change'); + await page.getByRole('button', { name: /annuler/i }).click(); + + // Should go back to list + await expect(page).toHaveURL(/\/admin\/classes$/); + + // Original name should still be there + await expect(page.getByText(originalName)).toBeVisible(); + await expect(page.getByText('Should-Not-Change')).not.toBeVisible(); + }); + }); + + // ============================================================================ + // AC3: Deletion blocked if students assigned + // ============================================================================ + test.describe('AC3: Deletion blocked if students assigned', () => { + // SKIP REASON: The Students module is not yet implemented. + // HasStudentsInClassHandler currently returns 0 (stub), so all classes + // appear empty and can be deleted. This test will be enabled once the + // Students module allows assigning students to classes. + // + // When enabled, this test should: + // 1. Create a class + // 2. Assign at least one student to it + // 3. Attempt to delete the class + // 4. Verify the error message "Vous devez d'abord réaffecter les élèves" + // 5. Verify the class still exists + test.skip('shows warning when trying to delete class with students', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + // Implementation pending Students module + }); + }); + + // ============================================================================ + // AC4: Empty class deletion (soft delete) + // ============================================================================ + test.describe('AC4: Empty class deletion (soft delete)', () => { + test('can delete an empty class', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a class to delete + await openNewClassDialog(page); + const className = `ToDelete-${Date.now()}`; + await page.locator('#class-name').fill(className); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(className)).toBeVisible(); + + // Find and click delete button + const classCard = page.locator('.class-card', { hasText: className }); + await classCard.getByRole('button', { name: /supprimer/i }).click(); + + // Confirmation modal should appear + const deleteModal = page.getByRole('alertdialog'); + await expect(deleteModal).toBeVisible({ timeout: 10000 }); + await expect(deleteModal.getByText(className)).toBeVisible(); + + // Confirm deletion + await deleteModal.getByRole('button', { name: /supprimer/i }).click(); + + // Modal should close and class should no longer appear in list + await expect(deleteModal).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(className)).not.toBeVisible({ timeout: 10000 }); + }); + + test('can cancel deletion', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a class + await openNewClassDialog(page); + const className = `NoDelete-${Date.now()}`; + await page.locator('#class-name').fill(className); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Find and click delete + const classCard = page.locator('.class-card', { hasText: className }); + await classCard.getByRole('button', { name: /supprimer/i }).click(); + + // Confirmation modal should appear + const deleteModal = page.getByRole('alertdialog'); + await expect(deleteModal).toBeVisible({ timeout: 10000 }); + + // Cancel deletion + await deleteModal.getByRole('button', { name: /annuler/i }).click(); + + // Modal should close and class should still be there + await expect(deleteModal).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(className)).toBeVisible(); + }); + }); + + // ============================================================================ + // Navigation + // ============================================================================ + test.describe('Navigation', () => { + test('can access classes page directly', async ({ page }) => { + await loginAsAdmin(page); + + // Navigate directly to classes page + await page.goto(`${ALPHA_URL}/admin/classes`); + + await expect(page).toHaveURL(/\/admin\/classes/); + await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible(); + }); + + test('breadcrumb navigation works on edit page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a class + await openNewClassDialog(page); + const className = `Breadcrumb-${Date.now()}`; + await page.locator('#class-name').fill(className); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Go to edit page + 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(); + + await expect(page).toHaveURL(/\/admin\/classes$/); + }); + }); + + // ============================================================================ + // Validation + // ============================================================================ + test.describe('Validation', () => { + test('shows validation for class name length', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + await openNewClassDialog(page); + + // Try a name that's too short (1 char) + await page.locator('#class-name').fill('A'); + + // The HTML5 minlength validation should prevent submission + // or show an error + const nameInput = page.locator('#class-name'); + const isInvalid = await nameInput.evaluate( + (el: HTMLInputElement) => !el.validity.valid + ); + expect(isInvalid).toBe(true); + }); + }); +}); diff --git a/frontend/e2e/login.spec.ts b/frontend/e2e/login.spec.ts index eee9767..f56be53 100644 --- a/frontend/e2e/login.spec.ts +++ b/frontend/e2e/login.spec.ts @@ -306,10 +306,10 @@ test.describe('Login Flow', () => { }); test.describe('Tenant Isolation', () => { - // Extract port from PLAYWRIGHT_BASE_URL or use default + // Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts) const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; const urlMatch = baseUrl.match(/:(\d+)$/); - const PORT = urlMatch ? urlMatch[1] : '5174'; + const PORT = urlMatch ? urlMatch[1] : '4173'; const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; const BETA_URL = `http://ecole-beta.classeo.local:${PORT}`; const ALPHA_EMAIL = 'tenant-test-alpha@example.com'; diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 285bae9..f988296 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -16,6 +16,11 @@ const config: PlaywrightTestConfig = { }, testDir: 'e2e', testMatch: /(.+\.)?(test|spec)\.[jt]s/, + // Run browsers sequentially in CI to avoid race conditions with shared database + // Classes tests use mode: 'serial' which only works within a single worker + fullyParallel: !process.env.CI, + // Use 1 worker in CI to ensure no parallel execution across different browser projects + workers: process.env.CI ? 1 : undefined, use: { baseURL, trace: 'on-first-retry', diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index 19ec5d6..a27e6bf 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -31,11 +31,11 @@ Gérer les utilisateurs Bientôt disponible - + Créer et gérer +
📅 Calendrier scolaire diff --git a/frontend/src/lib/constants/schoolLevels.ts b/frontend/src/lib/constants/schoolLevels.ts new file mode 100644 index 0000000..fdb8924 --- /dev/null +++ b/frontend/src/lib/constants/schoolLevels.ts @@ -0,0 +1,33 @@ +/** + * Liste des niveaux scolaires valides selon le référentiel Éducation Nationale. + * + * Utilisé pour : + * - La validation dans les formulaires + * - Les selects/dropdowns + * + * @see backend/src/Administration/Domain/Model/SchoolClass/SchoolLevels.php + */ +export const SCHOOL_LEVELS = [ + 'CP', + 'CE1', + 'CE2', + 'CM1', + 'CM2', + '6ème', + '5ème', + '4ème', + '3ème', + '2nde', + '1ère', + 'Terminale' +] as const; + +export type SchoolLevel = (typeof SCHOOL_LEVELS)[number]; + +/** + * Options pour les selects de niveaux scolaires. + */ +export const SCHOOL_LEVEL_OPTIONS = SCHOOL_LEVELS.map((level) => ({ + value: level, + label: level +})); diff --git a/frontend/src/routes/admin/classes/+page.svelte b/frontend/src/routes/admin/classes/+page.svelte new file mode 100644 index 0000000..55b6006 --- /dev/null +++ b/frontend/src/routes/admin/classes/+page.svelte @@ -0,0 +1,684 @@ + + + + Gestion des classes - Classeo + + +
+ + + {#if error} +
+ ⚠️ + {error} + +
+ {/if} + + {#if isLoading} +
+
+

Chargement des classes...

+
+ {:else if classes.length === 0} +
+ 🏫 +

Aucune classe

+

Commencez par créer votre première classe

+ +
+ {:else} +
+ {#each classes as schoolClass (schoolClass.id)} +
+
+

{schoolClass.name}

+ {#if schoolClass.level} + {schoolClass.level} + {/if} +
+
+ {#if schoolClass.capacity} + + 👥 + {schoolClass.capacity} places + + {/if} + + {schoolClass.status === 'active' ? 'Active' : 'Archivée'} + +
+
+ + +
+
+ {/each} +
+ {/if} +
+ + +{#if showCreateModal} + +{/if} + + +{#if showDeleteModal && classToDelete} + +{/if} + + diff --git a/frontend/src/routes/admin/classes/[id]/+page.svelte b/frontend/src/routes/admin/classes/[id]/+page.svelte new file mode 100644 index 0000000..0dcab86 --- /dev/null +++ b/frontend/src/routes/admin/classes/[id]/+page.svelte @@ -0,0 +1,487 @@ + + + + {schoolClass?.name ?? 'Modifier la classe'} - Classeo + + +
+ + + {#if isLoading} +
+
+

Chargement de la classe...

+
+ {:else if error && !schoolClass} +
+ ⚠️ +

Erreur

+

{error}

+ +
+ {:else if schoolClass} +
+
+

Modifier la classe

+

+ Créée le {new Date(schoolClass.createdAt).toLocaleDateString('fr-FR')} +

+
+ + {#if error} +
+ ⚠️ + {error} + +
+ {/if} + + {#if successMessage} +
+ + {successMessage} +
+ {/if} + +
{ e.preventDefault(); handleSave(); }}> +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+ {/if} +
+ +