diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 7330fc2..85ad58e 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -158,6 +158,10 @@ services: App\Administration\Domain\Repository\GradingConfigurationRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository + # Class Assignment (Story 3.0 - Affectation élèves aux classes) + App\Administration\Domain\Repository\ClassAssignmentRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineClassAssignmentRepository + # Teacher Assignment (Story 2.8 - Affectation enseignants) App\Administration\Domain\Repository\TeacherAssignmentRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherAssignmentRepository diff --git a/backend/migrations/Version20260221093719.php b/backend/migrations/Version20260221093719.php new file mode 100644 index 0000000..e000f4e --- /dev/null +++ b/backend/migrations/Version20260221093719.php @@ -0,0 +1,49 @@ +addSql(<<<'SQL' + CREATE TABLE class_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + school_class_id UUID NOT NULL, + academic_year_id UUID NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, academic_year_id), + CONSTRAINT fk_class_assignments_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_class_assignments_class FOREIGN KEY (school_class_id) REFERENCES school_classes(id) + ) + SQL); + + $this->addSql('CREATE INDEX idx_class_assignments_class ON class_assignments(school_class_id)'); + $this->addSql('CREATE INDEX idx_class_assignments_tenant ON class_assignments(tenant_id)'); + + $this->addSql('ALTER TABLE users ALTER COLUMN email DROP NOT NULL'); + $this->addSql('ALTER TABLE users ADD COLUMN student_number VARCHAR(11)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE class_assignments'); + $this->addSql('ALTER TABLE users DROP COLUMN student_number'); + $this->addSql("UPDATE users SET email = 'removed-' || id || '@placeholder.local' WHERE email IS NULL"); + $this->addSql('ALTER TABLE users ALTER COLUMN email SET NOT NULL'); + } +} diff --git a/backend/src/Administration/Application/Command/AssignStudentToClass/AssignStudentToClassCommand.php b/backend/src/Administration/Application/Command/AssignStudentToClass/AssignStudentToClassCommand.php new file mode 100644 index 0000000..0737a37 --- /dev/null +++ b/backend/src/Administration/Application/Command/AssignStudentToClass/AssignStudentToClassCommand.php @@ -0,0 +1,16 @@ +tenantId); + $studentId = UserId::fromString($command->studentId); + $classId = ClassId::fromString($command->classId); + $academicYearId = AcademicYearId::fromString($command->academicYearId); + + // Valider l'existence des entités référencées et leur tenant + $student = $this->userRepository->get($studentId); + + if (!$student->tenantId->equals($tenantId)) { + throw UserNotFoundException::withId($studentId); + } + + $class = $this->classRepository->get($classId); + + if (!$class->tenantId->equals($tenantId)) { + throw ClasseNotFoundException::withId($classId); + } + + if (!$class->status->peutRecevoirEleves()) { + throw ClasseNotFoundException::withId($classId); + } + + // Vérifier qu'il n'y a pas déjà une affectation pour cette année scolaire + $existing = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId); + + if ($existing !== null) { + throw EleveDejaAffecteException::pourAnneeScolaire($studentId); + } + + $assignment = ClassAssignment::affecter( + tenantId: $tenantId, + studentId: $studentId, + classId: $classId, + academicYearId: $academicYearId, + assignedAt: $this->clock->now(), + ); + + $this->classAssignmentRepository->save($assignment); + + return $assignment; + } +} diff --git a/backend/src/Administration/Application/Command/ChangeStudentClass/ChangeStudentClassCommand.php b/backend/src/Administration/Application/Command/ChangeStudentClass/ChangeStudentClassCommand.php new file mode 100644 index 0000000..8628705 --- /dev/null +++ b/backend/src/Administration/Application/Command/ChangeStudentClass/ChangeStudentClassCommand.php @@ -0,0 +1,16 @@ +tenantId); + $studentId = UserId::fromString($command->studentId); + $newClassId = ClassId::fromString($command->newClassId); + $academicYearId = AcademicYearId::fromString($command->academicYearId); + + // Valider l'existence de la nouvelle classe, son tenant et son statut + $class = $this->classRepository->get($newClassId); + + if (!$class->tenantId->equals($tenantId)) { + throw ClasseNotFoundException::withId($newClassId); + } + + if (!$class->status->peutRecevoirEleves()) { + throw ClasseNotFoundException::withId($newClassId); + } + + // Trouver l'affectation existante + $assignment = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId); + + if ($assignment === null) { + throw AffectationEleveNonTrouveeException::pourEleve($studentId); + } + + $assignment->changerClasse($newClassId, $this->clock->now()); + + $this->classAssignmentRepository->save($assignment); + + return $assignment; + } +} diff --git a/backend/src/Administration/Application/Command/CreateStudent/CreateStudentCommand.php b/backend/src/Administration/Application/Command/CreateStudent/CreateStudentCommand.php new file mode 100644 index 0000000..2f8b30a --- /dev/null +++ b/backend/src/Administration/Application/Command/CreateStudent/CreateStudentCommand.php @@ -0,0 +1,21 @@ +tenantId); + $classId = ClassId::fromString($command->classId); + $academicYearId = AcademicYearId::fromString($command->academicYearId); + + // Valider l'existence de la classe, son tenant et son statut + $class = $this->classRepository->get($classId); + + if (!$class->tenantId->equals($tenantId)) { + throw ClasseNotFoundException::withId($classId); + } + + if (!$class->status->peutRecevoirEleves()) { + throw ClasseNotFoundException::withId($classId); + } + + $now = $this->clock->now(); + + // Vérifier l'unicité de l'email si fourni + if ($command->email !== null) { + $email = new Email($command->email); + $existingUser = $this->userRepository->findByEmail($email, $tenantId); + + if ($existingUser !== null) { + throw EmailDejaUtiliseeException::dansTenant($email, $tenantId); + } + } + + $this->connection->beginTransaction(); + + try { + // Créer l'utilisateur + $user = $command->email !== null + ? User::inviter( + email: new Email($command->email), + role: Role::ELEVE, + tenantId: $tenantId, + schoolName: $command->schoolName, + firstName: $command->firstName, + lastName: $command->lastName, + invitedAt: $now, + dateNaissance: $command->dateNaissance !== null + ? new DateTimeImmutable($command->dateNaissance) + : null, + studentNumber: $command->studentNumber, + ) + : User::inscrire( + role: Role::ELEVE, + tenantId: $tenantId, + schoolName: $command->schoolName, + firstName: $command->firstName, + lastName: $command->lastName, + inscritAt: $now, + dateNaissance: $command->dateNaissance !== null + ? new DateTimeImmutable($command->dateNaissance) + : null, + studentNumber: $command->studentNumber, + ); + + $this->userRepository->save($user); + + // Affecter à la classe + $assignment = ClassAssignment::affecter( + tenantId: $tenantId, + studentId: $user->id, + classId: $classId, + academicYearId: $academicYearId, + assignedAt: $now, + ); + + $this->classAssignmentRepository->save($assignment); + + $this->connection->commit(); + } catch (Throwable $e) { + $this->connection->rollBack(); + + throw $e; + } + + return $user; + } +} diff --git a/backend/src/Administration/Application/Query/GetStudentsWithClass/GetStudentsWithClassHandler.php b/backend/src/Administration/Application/Query/GetStudentsWithClass/GetStudentsWithClassHandler.php new file mode 100644 index 0000000..683fa0f --- /dev/null +++ b/backend/src/Administration/Application/Query/GetStudentsWithClass/GetStudentsWithClassHandler.php @@ -0,0 +1,126 @@ + + */ + public function __invoke(GetStudentsWithClassQuery $query): PaginatedResult + { + $params = [ + 'tenant_id' => $query->tenantId, + 'academic_year_id' => $query->academicYearId, + 'role' => json_encode([Role::ELEVE->value]), + ]; + + $whereClause = 'u.tenant_id = :tenant_id AND u.roles::jsonb @> :role::jsonb'; + + if ($query->classId !== null) { + $whereClause .= ' AND ca.school_class_id = :class_id'; + $params['class_id'] = $query->classId; + } + + if ($query->search !== null && $query->search !== '') { + $whereClause .= ' AND (LOWER(u.last_name) LIKE :search OR LOWER(u.first_name) LIKE :search)'; + $params['search'] = '%' . mb_strtolower($query->search) . '%'; + } + + // Count total + $countSql = <<connection->fetchOne($countSql, $params); + $total = (int) $totalRaw; + + // Fetch paginated results + $offset = ($query->page - 1) * $query->limit; + + $selectSql = <<limit; + $params['offset'] = $offset; + + $rows = $this->connection->fetchAllAssociative($selectSql, $params); + + $items = array_map(static function (array $row): StudentWithClassDto { + /** @var string $id */ + $id = $row['id']; + /** @var string $firstName */ + $firstName = $row['first_name']; + /** @var string $lastName */ + $lastName = $row['last_name']; + /** @var string|null $email */ + $email = $row['email']; + /** @var string $statut */ + $statut = $row['statut']; + /** @var string|null $studentNumber */ + $studentNumber = $row['student_number']; + /** @var string|null $dateNaissance */ + $dateNaissance = $row['date_naissance']; + /** @var string|null $classId */ + $classId = $row['class_id']; + /** @var string|null $className */ + $className = $row['class_name']; + /** @var string|null $classLevel */ + $classLevel = $row['class_level']; + + return new StudentWithClassDto( + id: $id, + firstName: $firstName, + lastName: $lastName, + email: $email, + statut: $statut, + studentNumber: $studentNumber, + dateNaissance: $dateNaissance, + classId: $classId, + className: $className, + classLevel: $classLevel, + ); + }, $rows); + + return new PaginatedResult( + items: $items, + total: $total, + page: $query->page, + limit: $query->limit, + ); + } +} diff --git a/backend/src/Administration/Application/Query/GetStudentsWithClass/GetStudentsWithClassQuery.php b/backend/src/Administration/Application/Query/GetStudentsWithClass/GetStudentsWithClassQuery.php new file mode 100644 index 0000000..387605d --- /dev/null +++ b/backend/src/Administration/Application/Query/GetStudentsWithClass/GetStudentsWithClassQuery.php @@ -0,0 +1,27 @@ +page = max(1, $page); + $this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit)); + $this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null; + } +} diff --git a/backend/src/Administration/Application/Query/GetStudentsWithClass/StudentWithClassDto.php b/backend/src/Administration/Application/Query/GetStudentsWithClass/StudentWithClassDto.php new file mode 100644 index 0000000..259157c --- /dev/null +++ b/backend/src/Administration/Application/Query/GetStudentsWithClass/StudentWithClassDto.php @@ -0,0 +1,22 @@ +classAssignmentRepository->countByClass( + ClassId::fromString($query->classId), + ); } } diff --git a/backend/src/Administration/Domain/Event/EleveAffecteAClasse.php b/backend/src/Administration/Domain/Event/EleveAffecteAClasse.php new file mode 100644 index 0000000..bdf2d4c --- /dev/null +++ b/backend/src/Administration/Domain/Event/EleveAffecteAClasse.php @@ -0,0 +1,36 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->assignmentId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/EleveInscrit.php b/backend/src/Administration/Domain/Event/EleveInscrit.php new file mode 100644 index 0000000..f1d7e4d --- /dev/null +++ b/backend/src/Administration/Domain/Event/EleveInscrit.php @@ -0,0 +1,36 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->userId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/AffectationEleveNonTrouveeException.php b/backend/src/Administration/Domain/Exception/AffectationEleveNonTrouveeException.php new file mode 100644 index 0000000..4ff90c5 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/AffectationEleveNonTrouveeException.php @@ -0,0 +1,21 @@ +updatedAt = $createdAt; + } + + public static function affecter( + TenantId $tenantId, + UserId $studentId, + ClassId $classId, + AcademicYearId $academicYearId, + DateTimeImmutable $assignedAt, + ): self { + $assignment = new self( + id: ClassAssignmentId::generate(), + tenantId: $tenantId, + studentId: $studentId, + classId: $classId, + academicYearId: $academicYearId, + assignedAt: $assignedAt, + createdAt: $assignedAt, + ); + + $assignment->recordEvent(new EleveAffecteAClasse( + assignmentId: $assignment->id, + studentId: $assignment->studentId, + classId: $assignment->classId, + occurredOn: $assignedAt, + )); + + return $assignment; + } + + public function changerClasse(ClassId $newClassId, DateTimeImmutable $changedAt): void + { + $this->classId = $newClassId; + $this->updatedAt = $changedAt; + + $this->recordEvent(new EleveAffecteAClasse( + assignmentId: $this->id, + studentId: $this->studentId, + classId: $newClassId, + occurredOn: $changedAt, + )); + } + + /** + * Reconstitue une ClassAssignment depuis le stockage. + * + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + ClassAssignmentId $id, + TenantId $tenantId, + UserId $studentId, + ClassId $classId, + AcademicYearId $academicYearId, + DateTimeImmutable $assignedAt, + DateTimeImmutable $createdAt, + DateTimeImmutable $updatedAt, + ): self { + $assignment = new self( + id: $id, + tenantId: $tenantId, + studentId: $studentId, + classId: $classId, + academicYearId: $academicYearId, + assignedAt: $assignedAt, + createdAt: $createdAt, + ); + + $assignment->updatedAt = $updatedAt; + + return $assignment; + } +} diff --git a/backend/src/Administration/Domain/Model/ClassAssignment/ClassAssignmentId.php b/backend/src/Administration/Domain/Model/ClassAssignment/ClassAssignmentId.php new file mode 100644 index 0000000..2c0e810 --- /dev/null +++ b/backend/src/Administration/Domain/Model/ClassAssignment/ClassAssignmentId.php @@ -0,0 +1,11 @@ +invitedAt = $invitedAt; + $user->studentNumber = $studentNumber; $user->recordEvent(new UtilisateurInvite( userId: $user->id, @@ -294,6 +299,49 @@ final class User extends AggregateRoot return $user; } + /** + * Enrolls a student without email (no invitation sent). + * + * Used when an admin creates a student who does not have an email address. + * The student is enrolled with status INSCRIT and cannot log in until + * an email is provided later. + */ + public static function inscrire( + Role $role, + TenantId $tenantId, + string $schoolName, + string $firstName, + string $lastName, + DateTimeImmutable $inscritAt, + ?DateTimeImmutable $dateNaissance = null, + ?string $studentNumber = null, + ): self { + $user = new self( + id: UserId::generate(), + email: null, + roles: [$role], + tenantId: $tenantId, + schoolName: $schoolName, + statut: StatutCompte::INSCRIT, + dateNaissance: $dateNaissance, + createdAt: $inscritAt, + firstName: $firstName, + lastName: $lastName, + ); + + $user->studentNumber = $studentNumber; + + $user->recordEvent(new EleveInscrit( + userId: $user->id, + firstName: $firstName, + lastName: $lastName, + tenantId: $user->tenantId, + occurredOn: $inscritAt, + )); + + return $user; + } + /** * Resends the invitation for a user still awaiting activation. * @@ -426,7 +474,7 @@ final class User extends AggregateRoot */ public static function reconstitute( UserId $id, - Email $email, + ?Email $email, array $roles, TenantId $tenantId, string $schoolName, @@ -444,6 +492,7 @@ final class User extends AggregateRoot ImageRightsStatus $imageRightsStatus = ImageRightsStatus::NOT_SPECIFIED, ?DateTimeImmutable $imageRightsUpdatedAt = null, ?UserId $imageRightsUpdatedBy = null, + ?string $studentNumber = null, ): self { if ($roles === []) { throw new InvalidArgumentException('Un utilisateur doit avoir au moins un rôle.'); @@ -471,6 +520,7 @@ final class User extends AggregateRoot $user->imageRightsStatus = $imageRightsStatus; $user->imageRightsUpdatedAt = $imageRightsUpdatedAt; $user->imageRightsUpdatedBy = $imageRightsUpdatedBy; + $user->studentNumber = $studentNumber; return $user; } diff --git a/backend/src/Administration/Domain/Repository/ClassAssignmentRepository.php b/backend/src/Administration/Domain/Repository/ClassAssignmentRepository.php new file mode 100644 index 0000000..d42f599 --- /dev/null +++ b/backend/src/Administration/Domain/Repository/ClassAssignmentRepository.php @@ -0,0 +1,26 @@ + + */ +final readonly class ChangeStudentClassProcessor implements ProcessorInterface +{ + public function __construct( + private ChangeStudentClassHandler $handler, + private ClassRepository $classRepository, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + ) { + } + + /** + * @param StudentResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): StudentResource + { + if (!$this->authorizationChecker->isGranted(StudentVoter::MANAGE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à changer la classe d\'un élève.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + /** @var string $studentId */ + $studentId = $uriVariables['id'] ?? ''; + $academicYearId = $this->academicYearResolver->resolve('current') + ?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.'); + + try { + $command = new ChangeStudentClassCommand( + tenantId: $tenantId, + studentId: $studentId, + newClassId: $data->classId ?? '', + academicYearId: $academicYearId, + ); + + $assignment = ($this->handler)($command); + + // Dispatch domain events + foreach ($assignment->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + // Update the resource with new class info + $newClass = $this->classRepository->get($assignment->classId); + $data->classId = (string) $assignment->classId; + $data->className = (string) $newClass->name; + $data->classLevel = $newClass->level?->value; + + return $data; + } catch (AffectationEleveNonTrouveeException) { + throw new NotFoundHttpException('Élève non trouvé.'); + } catch (ClasseNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/CreateStudentProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/CreateStudentProcessor.php new file mode 100644 index 0000000..7ef87cb --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/CreateStudentProcessor.php @@ -0,0 +1,104 @@ + + */ +final readonly class CreateStudentProcessor implements ProcessorInterface +{ + public function __construct( + private CreateStudentHandler $handler, + private ClassRepository $classRepository, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + ) { + } + + /** + * @param StudentResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): StudentResource + { + if (!$this->authorizationChecker->isGranted(StudentVoter::CREATE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer un élève.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + $tenantConfig = $this->tenantContext->getCurrentTenantConfig(); + $academicYearId = $this->academicYearResolver->resolve('current') + ?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.'); + + try { + $command = new CreateStudentCommand( + tenantId: $tenantId, + schoolName: $tenantConfig->subdomain, + firstName: $data->firstName ?? '', + lastName: $data->lastName ?? '', + classId: $data->classId ?? '', + academicYearId: $academicYearId, + email: $data->email, + dateNaissance: $data->dateNaissance, + studentNumber: $data->studentNumber, + ); + + $user = ($this->handler)($command); + + // Dispatch domain events + foreach ($user->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + // Build the response from the created user and class info + $class = $this->classRepository->get(ClassId::fromString($data->classId ?? '')); + + $resource = new StudentResource(); + $resource->id = (string) $user->id; + $resource->firstName = $user->firstName; + $resource->lastName = $user->lastName; + $resource->email = $user->email !== null ? (string) $user->email : null; + $resource->statut = $user->statut->value; + $resource->classId = $data->classId; + $resource->className = (string) $class->name; + $resource->classLevel = $class->level?->value; + $resource->studentNumber = $user->studentNumber; + $resource->dateNaissance = $user->dateNaissance?->format('Y-m-d'); + + return $resource; + } catch (EmailDejaUtiliseeException $e) { + throw new ConflictHttpException($e->getMessage()); + } catch (ClasseNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/StudentCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/StudentCollectionProvider.php new file mode 100644 index 0000000..bd61e3d --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/StudentCollectionProvider.php @@ -0,0 +1,82 @@ + + */ +final readonly class StudentCollectionProvider implements ProviderInterface +{ + public function __construct( + private GetStudentsWithClassHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator + { + if (!$this->authorizationChecker->isGranted(StudentVoter::VIEW)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter les élèves.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + $academicYearId = $this->academicYearResolver->resolve('current') + ?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.'); + + /** @var array $filters */ + $filters = $context['filters'] ?? []; + /** @var int|string $rawPage */ + $rawPage = $filters['page'] ?? 1; + $page = (int) $rawPage; + /** @var int|string $rawItemsPerPage */ + $rawItemsPerPage = $filters['itemsPerPage'] ?? 30; + $itemsPerPage = (int) $rawItemsPerPage; + /** @var string|null $search */ + $search = isset($filters['search']) ? $filters['search'] : null; + /** @var string|null $classId */ + $classId = isset($filters['classId']) ? $filters['classId'] : null; + + $result = ($this->handler)(new GetStudentsWithClassQuery( + tenantId: $tenantId, + academicYearId: $academicYearId, + classId: $classId, + page: $page, + limit: $itemsPerPage, + search: $search, + )); + + $resources = array_map(StudentResource::fromDto(...), $result->items); + + return new TraversablePaginator( + new ArrayIterator($resources), + $page, + $itemsPerPage, + $result->total, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/StudentItemProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/StudentItemProvider.php new file mode 100644 index 0000000..a3e1ae5 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/StudentItemProvider.php @@ -0,0 +1,95 @@ + + */ +final readonly class StudentItemProvider implements ProviderInterface +{ + public function __construct( + private UserRepository $userRepository, + private ClassAssignmentRepository $classAssignmentRepository, + private ClassRepository $classRepository, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): StudentResource + { + if (!$this->authorizationChecker->isGranted(StudentVoter::VIEW)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter les élèves.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $studentId */ + $studentId = $uriVariables['id'] ?? ''; + $tenantId = $this->tenantContext->getCurrentTenantId(); + + $user = $this->userRepository->findById(UserId::fromString($studentId)); + + if ($user === null || (string) $user->tenantId !== (string) $tenantId || !$user->aLeRole(Role::ELEVE)) { + throw new NotFoundHttpException('Élève non trouvé.'); + } + + $resource = new StudentResource(); + $resource->id = (string) $user->id; + $resource->firstName = $user->firstName; + $resource->lastName = $user->lastName; + $resource->email = $user->email !== null ? (string) $user->email : null; + $resource->statut = $user->statut->value; + $resource->studentNumber = $user->studentNumber; + $resource->dateNaissance = $user->dateNaissance?->format('Y-m-d'); + + // Look up class assignment + $academicYearId = $this->academicYearResolver->resolve('current'); + + if ($academicYearId !== null) { + $assignment = $this->classAssignmentRepository->findByStudent( + $user->id, + AcademicYearId::fromString($academicYearId), + TenantId::fromString((string) $tenantId), + ); + + if ($assignment !== null) { + $resource->classId = (string) $assignment->classId; + + $class = $this->classRepository->findById($assignment->classId); + + if ($class !== null) { + $resource->className = (string) $class->name; + $resource->classLevel = $class->level?->value; + } + } + } + + return $resource; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/StudentResource.php b/backend/src/Administration/Infrastructure/Api/Resource/StudentResource.php new file mode 100644 index 0000000..b6c3dc0 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/StudentResource.php @@ -0,0 +1,91 @@ + ['Default', 'create']], + name: 'create_student', + ), + new Patch( + uriTemplate: '/students/{id}/class', + provider: StudentItemProvider::class, + processor: ChangeStudentClassProcessor::class, + validationContext: ['groups' => ['Default', 'change_class']], + name: 'change_student_class', + ), + ], +)] +final class StudentResource +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + #[Assert\NotBlank(message: 'Le prénom est requis.', groups: ['create'])] + #[Assert\Length(max: 100, maxMessage: 'Le prénom ne doit pas dépasser {{ limit }} caractères.')] + public ?string $firstName = null; + + #[Assert\NotBlank(message: 'Le nom est requis.', groups: ['create'])] + #[Assert\Length(max: 100, maxMessage: 'Le nom ne doit pas dépasser {{ limit }} caractères.')] + public ?string $lastName = null; + + #[Assert\Email(message: 'L\'email n\'est pas valide.')] + public ?string $email = null; + + #[Assert\NotBlank(message: 'La classe est requise.', groups: ['create', 'change_class'])] + public ?string $classId = null; + + public ?string $className = null; + public ?string $classLevel = null; + public ?string $statut = null; + public ?string $dateNaissance = null; + + #[Assert\Regex(pattern: '/^[A-Za-z0-9]{11}$/', message: 'L\'INE doit contenir exactement 11 caractères alphanumériques.')] + public ?string $studentNumber = null; + + public static function fromDto(StudentWithClassDto $dto): self + { + $resource = new self(); + $resource->id = $dto->id; + $resource->firstName = $dto->firstName; + $resource->lastName = $dto->lastName; + $resource->email = $dto->email; + $resource->statut = $dto->statut; + $resource->studentNumber = $dto->studentNumber; + $resource->dateNaissance = $dto->dateNaissance; + $resource->classId = $dto->classId; + $resource->className = $dto->className; + $resource->classLevel = $dto->classLevel; + + return $resource; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php index 6411f8e..cba105d 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php @@ -50,10 +50,12 @@ final readonly class CacheUserRepository implements UserRepository $this->usersCache->save($item); // Save email index for lookup (scoped to tenant) - $emailKey = $this->emailIndexKey($user->email, $user->tenantId); - $emailItem = $this->usersCache->getItem($emailKey); - $emailItem->set((string) $user->id); - $this->usersCache->save($emailItem); + if ($user->email !== null) { + $emailKey = $this->emailIndexKey($user->email, $user->tenantId); + $emailItem = $this->usersCache->getItem($emailKey); + $emailItem->set((string) $user->id); + $this->usersCache->save($emailItem); + } // Save tenant index for listing users $tenantKey = self::TENANT_INDEX_PREFIX . $user->tenantId; @@ -77,7 +79,7 @@ final readonly class CacheUserRepository implements UserRepository return null; } - /** @var array{id: string, email: string, roles?: string[], role?: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */ + /** @var array{id: string, email: string|null, roles?: string[], role?: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, student_number?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */ $data = $item->get(); return $this->deserialize($data); @@ -150,7 +152,7 @@ final readonly class CacheUserRepository implements UserRepository return [ 'id' => (string) $user->id, - 'email' => (string) $user->email, + 'email' => $user->email !== null ? (string) $user->email : null, 'roles' => array_map(static fn (Role $r) => $r->value, $user->roles), 'tenant_id' => (string) $user->tenantId, 'school_name' => $user->schoolName, @@ -167,6 +169,7 @@ final readonly class CacheUserRepository implements UserRepository 'image_rights_status' => $user->imageRightsStatus->value, 'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format('c'), 'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null, + 'student_number' => $user->studentNumber, 'consentement_parental' => $consentement !== null ? [ 'parent_id' => $consentement->parentId, 'eleve_id' => $consentement->eleveId, @@ -179,7 +182,7 @@ final readonly class CacheUserRepository implements UserRepository /** * @param array{ * id: string, - * email: string, + * email: string|null, * roles?: string[], * role?: string, * tenant_id: string, @@ -227,7 +230,7 @@ final readonly class CacheUserRepository implements UserRepository return User::reconstitute( id: UserId::fromString($data['id']), - email: new Email($data['email']), + email: $data['email'] !== null ? new Email($data['email']) : null, roles: $roles, tenantId: TenantId::fromString($data['tenant_id']), schoolName: $data['school_name'], @@ -245,6 +248,7 @@ final readonly class CacheUserRepository implements UserRepository imageRightsStatus: isset($data['image_rights_status']) ? ImageRightsStatus::from($data['image_rights_status']) : ImageRightsStatus::NOT_SPECIFIED, imageRightsUpdatedAt: ($data['image_rights_updated_at'] ?? null) !== null ? new DateTimeImmutable($data['image_rights_updated_at']) : null, imageRightsUpdatedBy: ($data['image_rights_updated_by'] ?? null) !== null ? UserId::fromString($data['image_rights_updated_by']) : null, + studentNumber: $data['student_number'] ?? null, ); } diff --git a/backend/src/Administration/Infrastructure/Persistence/Cache/CachedUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/Cache/CachedUserRepository.php index b55d557..293be7c 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Cache/CachedUserRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Cache/CachedUserRepository.php @@ -15,6 +15,9 @@ use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Repository\UserRepository; use App\Shared\Domain\Tenant\TenantId; use DateTimeImmutable; + +use function is_string; + use Override; use Psr\Cache\CacheItemPoolInterface; use Throwable; @@ -51,8 +54,10 @@ final readonly class CachedUserRepository implements UserRepository /** @var array $oldData */ $oldData = $existingItem->get(); /** @var string $oldEmail */ - $oldEmail = $oldData['email'] ?? ''; - if ($oldEmail !== '' && $oldEmail !== (string) $user->email) { + /** @var string|null $oldEmail */ + $oldEmail = $oldData['email'] ?? null; + $currentEmail = $user->email !== null ? (string) $user->email : null; + if ($oldEmail !== null && $oldEmail !== '' && $oldEmail !== $currentEmail) { /** @var string $oldTenantId */ $oldTenantId = $oldData['tenant_id'] ?? (string) $user->tenantId; $oldEmailKey = $this->emailIndexKey( @@ -67,10 +72,12 @@ final readonly class CachedUserRepository implements UserRepository $this->usersCache->save($existingItem); // Email index - $emailKey = $this->emailIndexKey($user->email, $user->tenantId); - $emailItem = $this->usersCache->getItem($emailKey); - $emailItem->set((string) $user->id); - $this->usersCache->save($emailItem); + if ($user->email !== null) { + $emailKey = $this->emailIndexKey($user->email, $user->tenantId); + $emailItem = $this->usersCache->getItem($emailKey); + $emailItem->set((string) $user->id); + $this->usersCache->save($emailItem); + } } catch (Throwable) { // Redis unavailable — PostgreSQL write succeeded, data is safe } @@ -172,10 +179,12 @@ final readonly class CachedUserRepository implements UserRepository $this->usersCache->save($item); // Email index - $emailKey = $this->emailIndexKey($user->email, $user->tenantId); - $emailItem = $this->usersCache->getItem($emailKey); - $emailItem->set((string) $user->id); - $this->usersCache->save($emailItem); + if ($user->email !== null) { + $emailKey = $this->emailIndexKey($user->email, $user->tenantId); + $emailItem = $this->usersCache->getItem($emailKey); + $emailItem->set((string) $user->id); + $this->usersCache->save($emailItem); + } } catch (Throwable) { // Redis unavailable } @@ -190,7 +199,7 @@ final readonly class CachedUserRepository implements UserRepository return [ 'id' => (string) $user->id, - 'email' => (string) $user->email, + 'email' => $user->email !== null ? (string) $user->email : null, 'roles' => array_map(static fn (Role $r) => $r->value, $user->roles), 'tenant_id' => (string) $user->tenantId, 'school_name' => $user->schoolName, @@ -207,6 +216,7 @@ final readonly class CachedUserRepository implements UserRepository 'image_rights_status' => $user->imageRightsStatus->value, 'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format('c'), 'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null, + 'student_number' => $user->studentNumber, 'consentement_parental' => $consentement !== null ? [ 'parent_id' => $consentement->parentId, 'eleve_id' => $consentement->eleveId, @@ -223,7 +233,7 @@ final readonly class CachedUserRepository implements UserRepository { /** @var string $id */ $id = $data['id']; - /** @var string $email */ + /** @var string|null $email */ $email = $data['email']; // Support both legacy single role ('role') and multi-role ('roles') format /** @var string[] $roleStrings */ @@ -275,7 +285,7 @@ final readonly class CachedUserRepository implements UserRepository return User::reconstitute( id: UserId::fromString($id), - email: new Email($email), + email: $email !== null ? new Email($email) : null, roles: $roles, tenantId: TenantId::fromString($tenantId), schoolName: $schoolName, @@ -293,6 +303,7 @@ final readonly class CachedUserRepository implements UserRepository imageRightsStatus: $imageRightsStatusValue !== null ? ImageRightsStatus::from($imageRightsStatusValue) : ImageRightsStatus::NOT_SPECIFIED, imageRightsUpdatedAt: $imageRightsUpdatedAt !== null ? new DateTimeImmutable($imageRightsUpdatedAt) : null, imageRightsUpdatedBy: $imageRightsUpdatedBy !== null ? UserId::fromString($imageRightsUpdatedBy) : null, + studentNumber: isset($data['student_number']) && is_string($data['student_number']) ? $data['student_number'] : null, ); } diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassAssignmentRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassAssignmentRepository.php new file mode 100644 index 0000000..1038131 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassAssignmentRepository.php @@ -0,0 +1,154 @@ +connection->executeStatement( + 'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) + VALUES (:id, :tenant_id, :user_id, :school_class_id, :academic_year_id, :assigned_at, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET + school_class_id = EXCLUDED.school_class_id, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $assignment->id, + 'tenant_id' => (string) $assignment->tenantId, + 'user_id' => (string) $assignment->studentId, + 'school_class_id' => (string) $assignment->classId, + 'academic_year_id' => (string) $assignment->academicYearId, + 'assigned_at' => $assignment->assignedAt->format(DateTimeImmutable::ATOM), + 'created_at' => $assignment->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $assignment->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } catch (UniqueConstraintViolationException) { + throw EleveDejaAffecteException::pourAnneeScolaire( + $assignment->studentId, + ); + } + } + + #[Override] + public function findById(ClassAssignmentId $id, TenantId $tenantId): ?ClassAssignment + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM class_assignments WHERE id = :id AND tenant_id = :tenant_id', + ['id' => (string) $id, 'tenant_id' => (string) $tenantId], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findByStudent(UserId $studentId, AcademicYearId $academicYearId, TenantId $tenantId): ?ClassAssignment + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM class_assignments + WHERE user_id = :user_id + AND academic_year_id = :academic_year_id + AND tenant_id = :tenant_id', + [ + 'user_id' => (string) $studentId, + 'academic_year_id' => (string) $academicYearId, + 'tenant_id' => (string) $tenantId, + ], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function countByClass(ClassId $classId): int + { + /** @var int|string|false $count */ + $count = $this->connection->fetchOne( + 'SELECT COUNT(*) FROM class_assignments WHERE school_class_id = :school_class_id', + ['school_class_id' => (string) $classId], + ); + + return (int) $count; + } + + #[Override] + public function findByClass(ClassId $classId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM class_assignments + WHERE school_class_id = :school_class_id + AND tenant_id = :tenant_id + ORDER BY created_at ASC', + [ + 'school_class_id' => (string) $classId, + 'tenant_id' => (string) $tenantId, + ], + ); + + return array_map(fn ($row) => $this->hydrate($row), $rows); + } + + /** + * @param array $row + */ + private function hydrate(array $row): ClassAssignment + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $studentId */ + $studentId = $row['user_id']; + /** @var string $classId */ + $classId = $row['school_class_id']; + /** @var string $academicYearId */ + $academicYearId = $row['academic_year_id']; + /** @var string $assignedAt */ + $assignedAt = $row['assigned_at']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return ClassAssignment::reconstitute( + id: ClassAssignmentId::fromString($id), + tenantId: TenantId::fromString($tenantId), + studentId: UserId::fromString($studentId), + classId: ClassId::fromString($classId), + academicYearId: AcademicYearId::fromString($academicYearId), + assignedAt: new DateTimeImmutable($assignedAt), + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php index 51b8046..5d5d34a 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php @@ -44,7 +44,7 @@ final readonly class DoctrineUserRepository implements UserRepository created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, - updated_at + student_number, updated_at ) VALUES ( :id, :tenant_id, :email, :first_name, :last_name, :roles, @@ -52,7 +52,7 @@ final readonly class DoctrineUserRepository implements UserRepository :created_at, :activated_at, :invited_at, :blocked_at, :blocked_reason, :consentement_parent_id, :consentement_eleve_id, :consentement_date, :consentement_ip, :image_rights_status, :image_rights_updated_at, :image_rights_updated_by, - NOW() + :student_number, NOW() ) ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, @@ -74,12 +74,13 @@ final readonly class DoctrineUserRepository implements UserRepository image_rights_status = EXCLUDED.image_rights_status, image_rights_updated_at = EXCLUDED.image_rights_updated_at, image_rights_updated_by = EXCLUDED.image_rights_updated_by, + student_number = EXCLUDED.student_number, updated_at = NOW() SQL, [ 'id' => (string) $user->id, 'tenant_id' => (string) $user->tenantId, - 'email' => (string) $user->email, + 'email' => $user->email !== null ? (string) $user->email : null, 'first_name' => $user->firstName, 'last_name' => $user->lastName, 'roles' => json_encode(array_map(static fn (Role $r) => $r->value, $user->roles)), @@ -99,6 +100,7 @@ final readonly class DoctrineUserRepository implements UserRepository 'image_rights_status' => $user->imageRightsStatus->value, 'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format(DateTimeImmutable::ATOM), 'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null, + 'student_number' => $user->studentNumber, ], ); } @@ -182,7 +184,7 @@ final readonly class DoctrineUserRepository implements UserRepository $id = $row['id']; /** @var string $tenantId */ $tenantId = $row['tenant_id']; - /** @var string $email */ + /** @var string|null $email */ $email = $row['email']; /** @var string $firstName */ $firstName = $row['first_name']; @@ -222,6 +224,8 @@ final readonly class DoctrineUserRepository implements UserRepository $imageRightsUpdatedAtValue = $row['image_rights_updated_at'] ?? null; /** @var string|null $imageRightsUpdatedByValue */ $imageRightsUpdatedByValue = $row['image_rights_updated_by'] ?? null; + /** @var string|null $studentNumber */ + $studentNumber = $row['student_number'] ?? null; /** @var string[]|null $roleValues */ $roleValues = json_decode($rolesJson, true); @@ -244,7 +248,7 @@ final readonly class DoctrineUserRepository implements UserRepository return User::reconstitute( id: UserId::fromString($id), - email: new Email($email), + email: $email !== null ? new Email($email) : null, roles: $roles, tenantId: TenantId::fromString($tenantId), schoolName: $schoolName, @@ -262,6 +266,7 @@ final readonly class DoctrineUserRepository implements UserRepository imageRightsStatus: $imageRightsStatusValue !== null ? ImageRightsStatus::from($imageRightsStatusValue) : ImageRightsStatus::NOT_SPECIFIED, imageRightsUpdatedAt: $imageRightsUpdatedAtValue !== null ? new DateTimeImmutable($imageRightsUpdatedAtValue) : null, imageRightsUpdatedBy: $imageRightsUpdatedByValue !== null ? UserId::fromString($imageRightsUpdatedByValue) : null, + studentNumber: $studentNumber, ); } } diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassAssignmentRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassAssignmentRepository.php new file mode 100644 index 0000000..273372f --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassAssignmentRepository.php @@ -0,0 +1,82 @@ + */ + private array $byId = []; + + #[Override] + public function save(ClassAssignment $assignment): void + { + $this->byId[(string) $assignment->id] = $assignment; + } + + #[Override] + public function findById(ClassAssignmentId $id, TenantId $tenantId): ?ClassAssignment + { + $assignment = $this->byId[(string) $id] ?? null; + + if ($assignment !== null && !$assignment->tenantId->equals($tenantId)) { + return null; + } + + return $assignment; + } + + #[Override] + public function findByStudent(UserId $studentId, AcademicYearId $academicYearId, TenantId $tenantId): ?ClassAssignment + { + foreach ($this->byId as $assignment) { + if ( + $assignment->studentId->equals($studentId) + && $assignment->academicYearId->equals($academicYearId) + && $assignment->tenantId->equals($tenantId) + ) { + return $assignment; + } + } + + return null; + } + + #[Override] + public function countByClass(ClassId $classId): int + { + $count = 0; + + foreach ($this->byId as $assignment) { + if ($assignment->classId->equals($classId)) { + ++$count; + } + } + + return $count; + } + + #[Override] + public function findByClass(ClassId $classId, TenantId $tenantId): array + { + $result = []; + + foreach ($this->byId as $assignment) { + if ($assignment->classId->equals($classId) && $assignment->tenantId->equals($tenantId)) { + $result[] = $assignment; + } + } + + return $result; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php index 18ac7d3..d48dc0b 100644 --- a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php @@ -25,7 +25,10 @@ final class InMemoryUserRepository implements UserRepository public function save(User $user): void { $this->byId[(string) $user->id] = $user; - $this->byTenantEmail[$this->emailKey($user->email, $user->tenantId)] = $user; + + if ($user->email !== null) { + $this->byTenantEmail[$this->emailKey($user->email, $user->tenantId)] = $user; + } } #[Override] diff --git a/backend/src/Administration/Infrastructure/Security/SecurityUserFactory.php b/backend/src/Administration/Infrastructure/Security/SecurityUserFactory.php index a16dc93..46ea746 100644 --- a/backend/src/Administration/Infrastructure/Security/SecurityUserFactory.php +++ b/backend/src/Administration/Infrastructure/Security/SecurityUserFactory.php @@ -6,6 +6,7 @@ namespace App\Administration\Infrastructure\Security; use App\Administration\Domain\Model\User\Role; use App\Administration\Domain\Model\User\User as DomainUser; +use LogicException; /** * Factory pour créer des SecurityUser depuis des Domain Users. @@ -18,9 +19,16 @@ final readonly class SecurityUserFactory { public function fromDomainUser(DomainUser $domainUser): SecurityUser { + if ($domainUser->email === null) { + throw new LogicException('Cannot create SecurityUser from a domain user without email.'); + } + + /** @var non-empty-string $email */ + $email = (string) $domainUser->email; + return new SecurityUser( userId: $domainUser->id, - email: (string) $domainUser->email, + email: $email, hashedPassword: $domainUser->hashedPassword ?? '', tenantId: $domainUser->tenantId, roles: array_values(array_map( diff --git a/backend/src/Administration/Infrastructure/Security/StudentVoter.php b/backend/src/Administration/Infrastructure/Security/StudentVoter.php new file mode 100644 index 0000000..5ba7733 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/StudentVoter.php @@ -0,0 +1,73 @@ + + */ +final class StudentVoter extends Voter +{ + public const string VIEW = 'STUDENT_VIEW'; + public const string CREATE = 'STUDENT_CREATE'; + public const string MANAGE = 'STUDENT_MANAGE'; + + private const array SUPPORTED_ATTRIBUTES = [ + self::VIEW, + self::CREATE, + self::MANAGE, + ]; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true) && $subject === null; + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof SecurityUser) { + return false; + } + + return $this->hasAnyRole($user->getRoles(), [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + Role::SECRETARIAT->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/AssignStudentToClass/AssignStudentToClassHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/AssignStudentToClass/AssignStudentToClassHandlerTest.php new file mode 100644 index 0000000..bae4ba1 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/AssignStudentToClass/AssignStudentToClassHandlerTest.php @@ -0,0 +1,242 @@ +classAssignmentRepository = new InMemoryClassAssignmentRepository(); + $this->userRepository = new InMemoryUserRepository(); + $this->classRepository = new InMemoryClassRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-21 10:00:00'); + } + }; + + $this->seedTestData(); + } + + #[Test] + public function itAssignsStudentToClass(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(); + + $assignment = $handler($command); + + self::assertTrue($assignment->classId->equals(ClassId::fromString(self::CLASS_ID))); + } + + #[Test] + public function itThrowsWhenStudentDoesNotExist(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(studentId: '550e8400-e29b-41d4-a716-446655440099'); + + $this->expectException(UserNotFoundException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenClassDoesNotExist(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(classId: '550e8400-e29b-41d4-a716-446655440099'); + + $this->expectException(ClasseNotFoundException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenStudentAlreadyAssigned(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(); + + // First assignment succeeds + $handler($command); + + $this->expectException(EleveDejaAffecteException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenClassBelongsToAnotherTenant(): void + { + $otherTenantClassId = '550e8400-e29b-41d4-a716-446655440030'; + + $classDifferentTenant = SchoolClass::reconstitute( + id: ClassId::fromString($otherTenantClassId), + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('6ème B'), + level: SchoolLevel::SIXIEME, + capacity: 30, + status: ClassStatus::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->classRepository->save($classDifferentTenant); + + $handler = $this->createHandler(); + $command = $this->createCommand(classId: $otherTenantClassId); + + $this->expectException(ClasseNotFoundException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenStudentBelongsToAnotherTenant(): void + { + $otherTenantStudentId = '550e8400-e29b-41d4-a716-446655440070'; + + $otherTenantStudent = User::reconstitute( + id: UserId::fromString($otherTenantStudentId), + email: null, + roles: [Role::ELEVE], + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'), + schoolName: 'Autre École', + statut: StatutCompte::INSCRIT, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15'), + hashedPassword: null, + activatedAt: null, + consentementParental: null, + ); + $this->userRepository->save($otherTenantStudent); + + $handler = $this->createHandler(); + $command = $this->createCommand(studentId: $otherTenantStudentId); + + $this->expectException(UserNotFoundException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenClassIsArchived(): void + { + $archivedClassId = '550e8400-e29b-41d4-a716-446655440031'; + + $archivedClass = SchoolClass::reconstitute( + id: ClassId::fromString($archivedClassId), + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('6ème C'), + level: SchoolLevel::SIXIEME, + capacity: 30, + status: ClassStatus::ARCHIVED, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->classRepository->save($archivedClass); + + $handler = $this->createHandler(); + $command = $this->createCommand(classId: $archivedClassId); + + $this->expectException(ClasseNotFoundException::class); + $handler($command); + } + + private function seedTestData(): void + { + $class = SchoolClass::reconstitute( + id: ClassId::fromString(self::CLASS_ID), + 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::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->classRepository->save($class); + + $student = User::reconstitute( + id: UserId::fromString(self::STUDENT_ID), + email: null, + roles: [Role::ELEVE], + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Test', + statut: StatutCompte::INSCRIT, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15'), + hashedPassword: null, + activatedAt: null, + consentementParental: null, + ); + $this->userRepository->save($student); + } + + private function createHandler(): AssignStudentToClassHandler + { + return new AssignStudentToClassHandler( + $this->classAssignmentRepository, + $this->userRepository, + $this->classRepository, + $this->clock, + ); + } + + private function createCommand( + ?string $studentId = null, + ?string $classId = null, + ): AssignStudentToClassCommand { + return new AssignStudentToClassCommand( + tenantId: self::TENANT_ID, + studentId: $studentId ?? self::STUDENT_ID, + classId: $classId ?? self::CLASS_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ChangeStudentClass/ChangeStudentClassHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ChangeStudentClass/ChangeStudentClassHandlerTest.php new file mode 100644 index 0000000..3a7216f --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ChangeStudentClass/ChangeStudentClassHandlerTest.php @@ -0,0 +1,202 @@ +classAssignmentRepository = new InMemoryClassAssignmentRepository(); + $this->classRepository = new InMemoryClassRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-21 10:00:00'); + } + }; + + $this->seedTestData(); + } + + #[Test] + public function itChangesStudentClass(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(); + + $assignment = $handler($command); + + self::assertTrue($assignment->classId->equals(ClassId::fromString(self::NEW_CLASS_ID))); + } + + #[Test] + public function itThrowsWhenNewClassDoesNotExist(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(newClassId: '550e8400-e29b-41d4-a716-446655440099'); + + $this->expectException(ClasseNotFoundException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenAssignmentNotFound(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(studentId: '550e8400-e29b-41d4-a716-446655440070'); + + $this->expectException(AffectationEleveNonTrouveeException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenClassBelongsToAnotherTenant(): void + { + $crossTenantClassId = '550e8400-e29b-41d4-a716-446655440030'; + $this->classRepository->save(SchoolClass::reconstitute( + id: ClassId::fromString($crossTenantClassId), + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('5ème B'), + level: SchoolLevel::CINQUIEME, + capacity: 30, + status: ClassStatus::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + )); + + $handler = $this->createHandler(); + $command = $this->createCommand(newClassId: $crossTenantClassId); + + $this->expectException(ClasseNotFoundException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenClassIsArchived(): void + { + $archivedClassId = '550e8400-e29b-41d4-a716-446655440031'; + $this->classRepository->save(SchoolClass::reconstitute( + id: ClassId::fromString($archivedClassId), + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('6ème C'), + level: SchoolLevel::SIXIEME, + capacity: 30, + status: ClassStatus::ARCHIVED, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + )); + + $handler = $this->createHandler(); + $command = $this->createCommand(newClassId: $archivedClassId); + + $this->expectException(ClasseNotFoundException::class); + $handler($command); + } + + private function seedTestData(): void + { + $oldClass = SchoolClass::reconstitute( + id: ClassId::fromString(self::OLD_CLASS_ID), + 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::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->classRepository->save($oldClass); + + $newClass = SchoolClass::reconstitute( + id: ClassId::fromString(self::NEW_CLASS_ID), + 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, + status: ClassStatus::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->classRepository->save($newClass); + + $assignment = ClassAssignment::affecter( + tenantId: TenantId::fromString(self::TENANT_ID), + studentId: UserId::fromString(self::STUDENT_ID), + classId: ClassId::fromString(self::OLD_CLASS_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + assignedAt: new DateTimeImmutable('2026-01-20'), + ); + $this->classAssignmentRepository->save($assignment); + } + + private function createHandler(): ChangeStudentClassHandler + { + return new ChangeStudentClassHandler( + $this->classAssignmentRepository, + $this->classRepository, + $this->clock, + ); + } + + private function createCommand( + ?string $newClassId = null, + ?string $studentId = null, + ): ChangeStudentClassCommand { + return new ChangeStudentClassCommand( + tenantId: self::TENANT_ID, + studentId: $studentId ?? self::STUDENT_ID, + newClassId: $newClassId ?? self::NEW_CLASS_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/CreateStudent/CreateStudentHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/CreateStudent/CreateStudentHandlerTest.php new file mode 100644 index 0000000..5fe7ff4 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/CreateStudent/CreateStudentHandlerTest.php @@ -0,0 +1,302 @@ +userRepository = new InMemoryUserRepository(); + $this->classAssignmentRepository = new InMemoryClassAssignmentRepository(); + $this->classRepository = new InMemoryClassRepository(); + $this->connection = $this->createMock(Connection::class); + $this->connection->method('beginTransaction'); + $this->connection->method('commit'); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-21 10:00:00'); + } + }; + + $this->seedTestData(); + } + + #[Test] + public function itCreatesStudentWithEmail(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(email: 'eleve@example.com'); + + $user = $handler($command); + + self::assertSame('Marie', $user->firstName); + self::assertSame('Dupont', $user->lastName); + self::assertSame(StatutCompte::EN_ATTENTE, $user->statut); + self::assertSame('eleve@example.com', (string) $user->email); + self::assertTrue($user->aLeRole(Role::ELEVE)); + } + + #[Test] + public function itCreatesStudentWithoutEmail(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(email: null); + + $user = $handler($command); + + self::assertSame('Marie', $user->firstName); + self::assertSame('Dupont', $user->lastName); + self::assertSame(StatutCompte::INSCRIT, $user->statut); + self::assertNull($user->email); + self::assertTrue($user->aLeRole(Role::ELEVE)); + } + + #[Test] + public function itAssignsStudentToClass(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(); + + $user = $handler($command); + + $assignment = $this->classAssignmentRepository->findByStudent( + $user->id, + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertNotNull($assignment); + self::assertTrue($assignment->classId->equals(ClassId::fromString(self::CLASS_ID))); + } + + #[Test] + public function itSetsStudentNumber(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(studentNumber: '12345678901'); + + $user = $handler($command); + + self::assertSame('12345678901', $user->studentNumber); + } + + #[Test] + public function itSetsDateNaissance(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(dateNaissance: '2015-06-15'); + + $user = $handler($command); + + self::assertNotNull($user->dateNaissance); + self::assertSame('2015-06-15', $user->dateNaissance->format('Y-m-d')); + } + + #[Test] + public function itThrowsWhenClassDoesNotExist(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(classId: '550e8400-e29b-41d4-a716-446655440099'); + + $this->expectException(ClasseNotFoundException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenEmailAlreadyUsedInTenant(): void + { + // Pre-populate with a user having the same email + $existing = User::reconstitute( + id: UserId::fromString('550e8400-e29b-41d4-a716-446655440060'), + email: new Email('existing@example.com'), + roles: [Role::ELEVE], + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Test', + statut: StatutCompte::EN_ATTENTE, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15'), + hashedPassword: null, + activatedAt: null, + consentementParental: null, + ); + $this->userRepository->save($existing); + + $handler = $this->createHandler(); + $command = $this->createCommand(email: 'existing@example.com'); + + $this->expectException(EmailDejaUtiliseeException::class); + $handler($command); + } + + #[Test] + public function itRollsBackTransactionOnFailure(): void + { + $connection = $this->createMock(Connection::class); + $connection->expects(self::once())->method('beginTransaction'); + $connection->expects(self::once())->method('rollBack'); + $connection->expects(self::never())->method('commit'); + + // Use a mock UserRepository that throws during save (inside the transaction) + $failingUserRepo = $this->createMock(UserRepository::class); + $failingUserRepo->method('findByEmail')->willReturn(null); + $failingUserRepo->method('save')->willThrowException(new RuntimeException('DB write failed')); + + $handler = new CreateStudentHandler( + $failingUserRepo, + $this->classAssignmentRepository, + $this->classRepository, + $connection, + $this->clock, + ); + + $command = $this->createCommand(); + + $this->expectException(RuntimeException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenClassBelongsToAnotherTenant(): void + { + $otherTenantClassId = '550e8400-e29b-41d4-a716-446655440080'; + $otherClass = SchoolClass::reconstitute( + id: ClassId::fromString($otherTenantClassId), + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('Autre tenant'), + level: SchoolLevel::SIXIEME, + capacity: 30, + status: ClassStatus::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->classRepository->save($otherClass); + + $handler = $this->createHandler(); + $command = $this->createCommand(classId: $otherTenantClassId); + + $this->expectException(ClasseNotFoundException::class); + $handler($command); + } + + #[Test] + public function itThrowsWhenClassIsArchived(): void + { + $archivedClassId = '550e8400-e29b-41d4-a716-446655440081'; + $archivedClass = SchoolClass::reconstitute( + id: ClassId::fromString($archivedClassId), + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + name: new ClassName('Archivée'), + level: SchoolLevel::SIXIEME, + capacity: 30, + status: ClassStatus::ARCHIVED, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->classRepository->save($archivedClass); + + $handler = $this->createHandler(); + $command = $this->createCommand(classId: $archivedClassId); + + $this->expectException(ClasseNotFoundException::class); + $handler($command); + } + + private function seedTestData(): void + { + $class = SchoolClass::reconstitute( + id: ClassId::fromString(self::CLASS_ID), + 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::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->classRepository->save($class); + } + + private function createHandler(): CreateStudentHandler + { + return new CreateStudentHandler( + $this->userRepository, + $this->classAssignmentRepository, + $this->classRepository, + $this->connection, + $this->clock, + ); + } + + private function createCommand( + ?string $email = 'eleve@example.com', + ?string $classId = null, + ?string $dateNaissance = null, + ?string $studentNumber = null, + ): CreateStudentCommand { + return new CreateStudentCommand( + tenantId: self::TENANT_ID, + schoolName: 'École Test', + firstName: 'Marie', + lastName: 'Dupont', + classId: $classId ?? self::CLASS_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + email: $email, + dateNaissance: $dateNaissance, + studentNumber: $studentNumber, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/HasStudentsInClass/HasStudentsInClassHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/HasStudentsInClass/HasStudentsInClassHandlerTest.php index e6cfa9d..ba0f63d 100644 --- a/backend/tests/Unit/Administration/Application/Query/HasStudentsInClass/HasStudentsInClassHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Query/HasStudentsInClass/HasStudentsInClassHandlerTest.php @@ -6,25 +6,36 @@ namespace App\Tests\Unit\Administration\Application\Query\HasStudentsInClass; use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassHandler; use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassQuery; +use App\Administration\Domain\Model\ClassAssignment\ClassAssignment; +use App\Administration\Domain\Model\SchoolClass\AcademicYearId; +use App\Administration\Domain\Model\SchoolClass\ClassId; +use App\Administration\Domain\Model\User\UserId; +use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository; +use App\Shared\Domain\Tenant\TenantId; +use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -/** - * Tests for HasStudentsInClassHandler. - * - * Currently returns 0 (stub) until the student module is available. - * These tests document the expected behavior for when the implementation - * is completed. - */ final class HasStudentsInClassHandlerTest extends TestCase { - #[Test] - public function returnsZeroForAnyClass(): void + private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001'; + private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020'; + private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040'; + + private InMemoryClassAssignmentRepository $repository; + + protected function setUp(): void { - $handler = new HasStudentsInClassHandler(); + $this->repository = new InMemoryClassAssignmentRepository(); + } + + #[Test] + public function returnsZeroWhenNoStudentsAssigned(): void + { + $handler = new HasStudentsInClassHandler($this->repository); $query = new HasStudentsInClassQuery( - classId: '550e8400-e29b-41d4-a716-446655440020', + classId: self::CLASS_ID, ); $result = ($handler)($query); @@ -33,28 +44,41 @@ final class HasStudentsInClassHandlerTest extends TestCase } #[Test] - public function returnsIntegerType(): void + public function returnsCountOfAssignedStudents(): void { - $handler = new HasStudentsInClassHandler(); + $this->addAssignment('550e8400-e29b-41d4-a716-446655440010'); + $this->addAssignment('550e8400-e29b-41d4-a716-446655440011'); - $query = new HasStudentsInClassQuery( - classId: '550e8400-e29b-41d4-a716-446655440021', - ); + $handler = new HasStudentsInClassHandler($this->repository); - $result = ($handler)($query); + $result = ($handler)(new HasStudentsInClassQuery(classId: self::CLASS_ID)); - self::assertIsInt($result); + self::assertSame(2, $result); } #[Test] public function isConsistentAcrossMultipleCalls(): void { - $handler = new HasStudentsInClassHandler(); - $classId = '550e8400-e29b-41d4-a716-446655440022'; + $this->addAssignment('550e8400-e29b-41d4-a716-446655440010'); - $result1 = ($handler)(new HasStudentsInClassQuery(classId: $classId)); - $result2 = ($handler)(new HasStudentsInClassQuery(classId: $classId)); + $handler = new HasStudentsInClassHandler($this->repository); + + $result1 = ($handler)(new HasStudentsInClassQuery(classId: self::CLASS_ID)); + $result2 = ($handler)(new HasStudentsInClassQuery(classId: self::CLASS_ID)); self::assertSame($result1, $result2); + self::assertSame(1, $result1); + } + + private function addAssignment(string $studentId): void + { + $assignment = ClassAssignment::affecter( + tenantId: TenantId::fromString(self::TENANT_ID), + studentId: UserId::fromString($studentId), + classId: ClassId::fromString(self::CLASS_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + assignedAt: new DateTimeImmutable('2026-02-21 10:00:00'), + ); + $this->repository->save($assignment); } } diff --git a/backend/tests/Unit/Administration/Domain/Model/ClassAssignment/ClassAssignmentTest.php b/backend/tests/Unit/Administration/Domain/Model/ClassAssignment/ClassAssignmentTest.php new file mode 100644 index 0000000..d26a299 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/ClassAssignment/ClassAssignmentTest.php @@ -0,0 +1,128 @@ +createAssignment(); + + self::assertNotEmpty((string) $assignment->id); + self::assertTrue($assignment->tenantId->equals(TenantId::fromString(self::TENANT_ID))); + self::assertTrue($assignment->studentId->equals(UserId::fromString(self::STUDENT_ID))); + self::assertTrue($assignment->classId->equals(ClassId::fromString(self::CLASS_ID))); + self::assertTrue($assignment->academicYearId->equals(AcademicYearId::fromString(self::ACADEMIC_YEAR_ID))); + } + + #[Test] + public function affecterEnregistreEvenementEleveAffecteAClasse(): void + { + $assignment = $this->createAssignment(); + + $events = $assignment->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(EleveAffecteAClasse::class, $events[0]); + self::assertTrue($events[0]->studentId->equals(UserId::fromString(self::STUDENT_ID))); + self::assertTrue($events[0]->classId->equals(ClassId::fromString(self::CLASS_ID))); + } + + #[Test] + public function affecterInitialiseUpdatedAtAvecCreatedAt(): void + { + $now = new DateTimeImmutable('2026-02-21 10:00:00'); + + $assignment = ClassAssignment::affecter( + tenantId: TenantId::fromString(self::TENANT_ID), + studentId: UserId::fromString(self::STUDENT_ID), + classId: ClassId::fromString(self::CLASS_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + assignedAt: $now, + ); + + self::assertSame($now, $assignment->createdAt); + self::assertSame($now, $assignment->updatedAt); + self::assertSame($now, $assignment->assignedAt); + } + + #[Test] + public function changerClasseModifieLaClasseEtUpdatedAt(): void + { + $assignment = $this->createAssignment(); + $assignment->pullDomainEvents(); // Clear creation event + + $newClassId = ClassId::fromString('550e8400-e29b-41d4-a716-446655440099'); + $changedAt = new DateTimeImmutable('2026-03-15 14:00:00'); + + $assignment->changerClasse($newClassId, $changedAt); + + self::assertTrue($assignment->classId->equals($newClassId)); + self::assertSame($changedAt, $assignment->updatedAt); + } + + #[Test] + public function changerClasseEnregistreEvenementEleveAffecteAClasse(): void + { + $assignment = $this->createAssignment(); + $assignment->pullDomainEvents(); // Clear creation event + + $newClassId = ClassId::fromString('550e8400-e29b-41d4-a716-446655440099'); + $changedAt = new DateTimeImmutable('2026-03-15 14:00:00'); + + $assignment->changerClasse($newClassId, $changedAt); + + $events = $assignment->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(EleveAffecteAClasse::class, $events[0]); + self::assertTrue($events[0]->classId->equals($newClassId)); + self::assertSame($changedAt, $events[0]->occurredOn()); + } + + #[Test] + public function reconstituteNeGenereAucunEvenement(): void + { + $assignment = ClassAssignment::reconstitute( + id: \App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId::fromString('550e8400-e29b-41d4-a716-446655440050'), + tenantId: TenantId::fromString(self::TENANT_ID), + studentId: UserId::fromString(self::STUDENT_ID), + classId: ClassId::fromString(self::CLASS_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + assignedAt: new DateTimeImmutable('2026-02-21 10:00:00'), + createdAt: new DateTimeImmutable('2026-02-21 10:00:00'), + updatedAt: new DateTimeImmutable('2026-02-21 12:00:00'), + ); + + self::assertEmpty($assignment->pullDomainEvents()); + } + + private function createAssignment(): ClassAssignment + { + return ClassAssignment::affecter( + tenantId: TenantId::fromString(self::TENANT_ID), + studentId: UserId::fromString(self::STUDENT_ID), + classId: ClassId::fromString(self::CLASS_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + assignedAt: new DateTimeImmutable('2026-02-21 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/StudentVoterTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/StudentVoterTest.php new file mode 100644 index 0000000..e7e942c --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Security/StudentVoterTest.php @@ -0,0 +1,175 @@ +voter = new StudentVoter(); + } + + #[Test] + public function itAbstainsForUnrelatedAttributes(): void + { + $token = $this->tokenWithSecurityUser(Role::ADMIN->value); + + $result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']); + + self::assertSame(Voter::ACCESS_ABSTAIN, $result); + } + + #[Test] + public function itDeniesAccessToUnauthenticatedUsers(): void + { + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(null); + + $result = $this->voter->vote($token, null, [StudentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesAccessToNonSecurityUserInstances(): void + { + $user = $this->createMock(UserInterface::class); + $user->method('getRoles')->willReturn([Role::ADMIN->value]); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + $result = $this->voter->vote($token, null, [StudentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + // --- VIEW --- + + #[Test] + #[DataProvider('allowedRolesProvider')] + public function itGrantsViewToAllowedRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [StudentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_GRANTED, $result); + } + + #[Test] + #[DataProvider('deniedRolesProvider')] + public function itDeniesViewToOtherRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [StudentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + // --- CREATE --- + + #[Test] + #[DataProvider('allowedRolesProvider')] + public function itGrantsCreateToAllowedRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [StudentVoter::CREATE]); + + self::assertSame(Voter::ACCESS_GRANTED, $result); + } + + #[Test] + #[DataProvider('deniedRolesProvider')] + public function itDeniesCreateToOtherRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [StudentVoter::CREATE]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + // --- MANAGE --- + + #[Test] + #[DataProvider('allowedRolesProvider')] + public function itGrantsManageToAllowedRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [StudentVoter::MANAGE]); + + self::assertSame(Voter::ACCESS_GRANTED, $result); + } + + #[Test] + #[DataProvider('deniedRolesProvider')] + public function itDeniesManageToOtherRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [StudentVoter::MANAGE]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + // --- Data Providers --- + + /** + * @return iterable + */ + public static function allowedRolesProvider(): iterable + { + yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value]; + yield 'ADMIN' => [Role::ADMIN->value]; + yield 'SECRETARIAT' => [Role::SECRETARIAT->value]; + } + + /** + * @return iterable + */ + public static function deniedRolesProvider(): iterable + { + yield 'PROF' => [Role::PROF->value]; + yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value]; + yield 'PARENT' => [Role::PARENT->value]; + yield 'ELEVE' => [Role::ELEVE->value]; + } + + private function tokenWithSecurityUser(string $role): TokenInterface + { + $securityUser = new SecurityUser( + UserId::fromString('550e8400-e29b-41d4-a716-446655440010'), + 'test@example.com', + 'hashed_password', + TenantId::fromString(self::TENANT_ID), + [$role], + ); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($securityUser); + + return $token; + } +} diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts index b38eb63..608d0e8 100644 --- a/frontend/e2e/classes.spec.ts +++ b/frontend/e2e/classes.spec.ts @@ -30,6 +30,10 @@ test.describe('Classes Management (Story 2.1)', () => { { encoding: 'utf-8' } ); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM class_assignments WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, + { encoding: 'utf-8' } + ); 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' } @@ -78,6 +82,10 @@ test.describe('Classes Management (Story 2.1)', () => { 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 class_assignments WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, + { encoding: 'utf-8' } + ); 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' } diff --git a/frontend/e2e/student-creation.spec.ts b/frontend/e2e/student-creation.spec.ts new file mode 100644 index 0000000..0f0bd63 --- /dev/null +++ b/frontend/e2e/student-creation.spec.ts @@ -0,0 +1,642 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const ADMIN_EMAIL = 'e2e-student-creation-admin@example.com'; +const ADMIN_PASSWORD = 'StudentCreationTest123'; + +const UNIQUE_SUFFIX = Date.now(); + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runCommand(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId, academicYearId }; +} + +async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function waitForStudentsPage(page: import('@playwright/test').Page) { + await expect( + page.getByRole('heading', { name: /gestion des élèves/i }) + ).toBeVisible({ timeout: 15000 }); + await expect( + page.locator('.empty-state, .students-table, .alert-error') + ).toBeVisible({ timeout: 15000 }); +} + +let testClassId: string; +let testClassId2: string; + +function createBulkStudents(count: number, tenantId: string, classId: string, academicYearId: string) { + for (let i = 0; i < count; i++) { + const suffix = UNIQUE_SUFFIX.toString().slice(-8); + const paddedI = String(i).padStart(4, '0'); + const userId = `00000000-e2e0-4000-8000-${suffix}${paddedI}`; + const assignmentId = `00000001-e2e0-4000-8000-${suffix}${paddedI}`; + try { + runCommand( + `INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${userId}', '${tenantId}', NULL, 'Pagination${i}', 'Student-${UNIQUE_SUFFIX}', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING` + ); + runCommand( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) VALUES ('${assignmentId}', '${tenantId}', '${userId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (id) DO NOTHING` + ); + } catch { + // Student may already exist + } + } +} + +test.describe('Student Creation & Management (Story 3.0)', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // 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' } + ); + + // Create a test class for student assignment + const { schoolId, academicYearId } = resolveDeterministicIds(); + testClassId = `e2e-class-${UNIQUE_SUFFIX}`.substring(0, 36).padEnd(36, '0'); + // Use a valid UUID format + testClassId = `00000000-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`; + + try { + runCommand( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class ${UNIQUE_SUFFIX}', 'CM2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING` + ); + } catch { + // Class may already exist + } + + // Create a second test class for change-class tests + testClassId2 = `00000001-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`; + try { + runCommand( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId2}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class 2 ${UNIQUE_SUFFIX}', 'CE2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING` + ); + } catch { + // Class may already exist + } + + // Create 31 students for pagination tests (itemsPerPage = 30) + createBulkStudents(31, TENANT_ID, testClassId, academicYearId); + }); + + // ============================================================================ + // Navigation + // ============================================================================ + test.describe('Navigation', () => { + test('students page is accessible from admin nav', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + + await expect(page.getByRole('heading', { name: /gestion des élèves/i })).toBeVisible({ + timeout: 10000 + }); + }); + + test('page title is set correctly', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + + await expect(page).toHaveTitle(/gestion des élèves/i); + }); + + test('nav menu shows Élèves link', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + + // The nav should have an active "Élèves" link + await expect(page.locator('nav a', { hasText: /élèves/i })).toBeVisible({ + timeout: 10000 + }); + }); + }); + + // ============================================================================ + // AC1: Create Student Form + // ============================================================================ + test.describe('AC1 - Create Student Modal', () => { + test('can open create student modal', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await expect( + dialog.getByRole('heading', { name: /nouvel élève/i }) + ).toBeVisible(); + }); + + test('modal has all required fields', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Required fields + await expect(dialog.locator('#student-lastname')).toBeVisible(); + await expect(dialog.locator('#student-firstname')).toBeVisible(); + await expect(dialog.locator('#student-class')).toBeVisible(); + + // Optional fields + await expect(dialog.locator('#student-email')).toBeVisible(); + await expect(dialog.locator('#student-dob')).toBeVisible(); + await expect(dialog.locator('#student-ine')).toBeVisible(); + }); + + test('class dropdown uses optgroup by level', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Check optgroups exist in the class select + const optgroups = dialog.locator('#student-class optgroup'); + const count = await optgroups.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test('can close modal with cancel button', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + await dialog.getByRole('button', { name: /annuler/i }).click(); + await expect(dialog).not.toBeVisible(); + }); + + test('can close modal with Escape key', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Focus the modal so the keydown handler receives the event + await dialog.focus(); + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + }); + }); + + // ============================================================================ + // AC2: Create student with class assignment + // ============================================================================ + test.describe('AC2 - Student Creation', () => { + test('can create a student without email', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Fill required fields + await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`); + await dialog.locator('#student-firstname').fill('Marie'); + + // Select first available class + await dialog.locator('#student-class').selectOption({ index: 1 }); + + // Submit + await dialog.getByRole('button', { name: /créer l'élève/i }).click(); + + // Success message should appear + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toContainText(/inscrit/i); + + // Student should appear in the list + await expect(page.locator('.students-table')).toBeVisible({ timeout: 5000 }); + await expect( + page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }) + ).toBeVisible(); + }); + + test('can create a student with email', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + await dialog.locator('#student-lastname').fill(`Martin-${UNIQUE_SUFFIX}`); + await dialog.locator('#student-firstname').fill('Jean'); + await dialog.locator('#student-email').fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`); + await dialog.locator('#student-class').selectOption({ index: 1 }); + + await dialog.getByRole('button', { name: /créer l'élève/i }).click(); + + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toContainText(/invitation/i); + }); + + test('"Créer un autre" keeps modal open', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Check "Créer un autre élève" + await dialog.locator('input[type="checkbox"]').check(); + + await dialog.locator('#student-lastname').fill(`Bernard-${UNIQUE_SUFFIX}`); + await dialog.locator('#student-firstname').fill('Luc'); + await dialog.locator('#student-class').selectOption({ index: 1 }); + + await dialog.getByRole('button', { name: /créer l'élève/i }).click(); + + // Success should appear but modal stays open + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(dialog).toBeVisible(); + + // Form fields should be cleared + await expect(dialog.locator('#student-lastname')).toHaveValue(''); + await expect(dialog.locator('#student-firstname')).toHaveValue(''); + + // Close the modal + await dialog.getByRole('button', { name: /annuler/i }).click(); + }); + }); + + // ============================================================================ + // AC3: Data validation + // ============================================================================ + test.describe('AC3 - Validation', () => { + test('[P0] INE validation shows error for invalid format', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Fill required fields + await dialog.locator('#student-firstname').fill('Test'); + await dialog.locator('#student-lastname').fill('INEValidation'); + await dialog.locator('#student-class').selectOption({ index: 1 }); + + // Enter invalid INE (too short) + await dialog.locator('#student-ine').fill('ABC'); + + // Error message should appear + await expect(dialog.locator('.field-error')).toBeVisible(); + await expect(dialog.locator('.field-error')).toContainText(/11 caractères/i); + + // Submit button should be disabled + await expect( + dialog.getByRole('button', { name: /créer l'élève/i }) + ).toBeDisabled(); + + // Fix INE to valid format (11 alphanumeric chars) + await dialog.locator('#student-ine').fill('12345678901'); + + // Error should disappear + await expect(dialog.locator('.field-error')).not.toBeVisible(); + + // Submit button should be enabled + await expect( + dialog.getByRole('button', { name: /créer l'élève/i }) + ).toBeEnabled(); + + // Close modal without creating + await dialog.getByRole('button', { name: /annuler/i }).click(); + }); + + test('[P0] shows error when email is already used', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Use the same email as the student created in AC2 + await dialog.locator('#student-firstname').fill('Doublon'); + await dialog.locator('#student-lastname').fill('Email'); + await dialog + .locator('#student-email') + .fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`); + await dialog.locator('#student-class').selectOption({ index: 1 }); + + await dialog.getByRole('button', { name: /créer l'élève/i }).click(); + + // Error should appear (from API) + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-error')).toContainText(/email/i); + + // Close modal + await dialog.getByRole('button', { name: /annuler/i }).click(); + }); + + test('[P0] shows duplicate warning for same name in same class', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + await page.getByRole('button', { name: /nouvel élève/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Use same name as student created in AC2 + await dialog.locator('#student-firstname').fill('Marie'); + await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`); + await dialog.locator('#student-class').selectOption({ index: 1 }); + + // Submit — should trigger duplicate check + await dialog.getByRole('button', { name: /créer l'élève/i }).click(); + + // Duplicate warning should appear + await expect(dialog.locator('.duplicate-warning')).toBeVisible({ + timeout: 10000 + }); + await expect(dialog.locator('.duplicate-warning')).toContainText(/existe déjà/i); + + // Click "Annuler" — warning disappears + await dialog + .locator('.duplicate-warning') + .getByRole('button', { name: /annuler/i }) + .click(); + await expect(dialog.locator('.duplicate-warning')).not.toBeVisible(); + + // Submit again — warning reappears + await dialog.getByRole('button', { name: /créer l'élève/i }).click(); + await expect(dialog.locator('.duplicate-warning')).toBeVisible({ + timeout: 10000 + }); + + // Click "Continuer" — creation succeeds despite duplicate + await dialog + .locator('.duplicate-warning') + .getByRole('button', { name: /continuer/i }) + .click(); + + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC4: Students listing page + // ============================================================================ + test.describe('AC4 - Students List', () => { + test('displays students in a table', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + // Table should have headers + await expect(page.locator('.students-table th', { hasText: /nom/i })).toBeVisible(); + await expect(page.locator('.students-table th', { hasText: /classe/i })).toBeVisible(); + await expect(page.locator('.students-table th', { hasText: /statut/i })).toBeVisible(); + }); + + test('search filters students by name', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + // Search for a student created earlier + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Should find the student (use .first() because AC3 duplicate test creates a second one) + await expect( + page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first() + ).toBeVisible({ timeout: 10000 }); + }); + + test('rows are clickable and navigate to student detail', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + // Search for specific student + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Click on the row (use .first() because AC3 duplicate test creates a second one) + const row = page.locator('.clickable-row', { + hasText: `Dupont-${UNIQUE_SUFFIX}` + }).first(); + await row.click(); + + // Should navigate to student detail page + await expect(page).toHaveURL(/\/admin\/students\/[a-f0-9-]+/); + await expect( + page.getByRole('heading', { name: /fiche élève/i }) + ).toBeVisible({ timeout: 10000 }); + }); + + test('[P1] pagination appears when more than 30 students and navigation works', async ({ + page + }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + // Pagination nav should be visible (31 students created in beforeAll) + const paginationNav = page.locator('nav[aria-label="Pagination"]'); + await expect(paginationNav).toBeVisible({ timeout: 10000 }); + + // "Précédent" button should be disabled on page 1 + await expect( + paginationNav.getByRole('button', { name: /précédent/i }) + ).toBeDisabled(); + + // Page 1 button should be active + await expect( + paginationNav.getByRole('button', { name: 'Page 1', exact: true }) + ).toHaveAttribute('aria-current', 'page'); + + // Click "Suivant" to go to page 2 + await paginationNav.getByRole('button', { name: /suivant/i }).click(); + await page.waitForLoadState('networkidle'); + + // URL should contain page=2 + await expect(page).toHaveURL(/page=2/); + + // Page 2 button should now be active + await expect( + paginationNav.getByRole('button', { name: 'Page 2', exact: true }) + ).toHaveAttribute('aria-current', 'page'); + + // "Précédent" should now be enabled + await expect( + paginationNav.getByRole('button', { name: /précédent/i }) + ).toBeEnabled(); + + // Table or content should still be visible (not error) + await expect( + page.locator('.students-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC5: Change student class + // ============================================================================ + test.describe('AC5 - Change Student Class', () => { + test('[P1] can change class via modal with confirmation and optimistic update', async ({ + page + }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + // Search for a student created earlier + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Find the student row and click "Changer de classe" (use .first() because AC3 duplicate test creates a second one) + const row = page.locator('.clickable-row', { + hasText: `Dupont-${UNIQUE_SUFFIX}` + }).first(); + await expect(row).toBeVisible({ timeout: 10000 }); + await row.locator('button', { hasText: /changer de classe/i }).click(); + + // Change class modal should open + const dialog = page.locator('[role="alertdialog"]'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await expect( + dialog.getByRole('heading', { name: /changer de classe/i }) + ).toBeVisible(); + + // Description should mention the student name + await expect(dialog.locator('#change-class-description')).toContainText( + `Dupont-${UNIQUE_SUFFIX}` + ); + + // Select a different class + await dialog.locator('#change-class-select').selectOption({ index: 1 }); + + // Confirmation text should appear + await expect(dialog.locator('.change-confirm-info')).toBeVisible(); + await expect(dialog.locator('.change-confirm-info')).toContainText(/transférer/i); + + // Click "Confirmer le transfert" + await dialog.getByRole('button', { name: /confirmer le transfert/i }).click(); + + // Success message should appear + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toContainText(/transféré/i); + + // Modal should close + await expect(dialog).not.toBeVisible(); + }); + }); + + // ============================================================================ + // AC6: Filter by class + // ============================================================================ + test.describe('AC6 - Class Filter', () => { + test('class filter dropdown exists with optgroups', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + const filterSelect = page.locator('#filter-class'); + await expect(filterSelect).toBeVisible(); + + // Should have optgroups + const optgroups = filterSelect.locator('optgroup'); + const count = await optgroups.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test('[P2] selecting a class filters the student list', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/students`); + await waitForStudentsPage(page); + + // Select a class in the filter + const filterSelect = page.locator('#filter-class'); + await filterSelect.selectOption({ index: 1 }); + + // Wait for the list to reload + await page.waitForLoadState('networkidle'); + + // URL should contain classId parameter + await expect(page).toHaveURL(/classId=/); + + // The page should still show the table or empty state (not an error) + await expect( + page.locator('.students-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Reset filter + await filterSelect.selectOption({ value: '' }); + await page.waitForLoadState('networkidle'); + + // classId should be removed from URL (polling assertion for reliability) + await expect(page).not.toHaveURL(/classId=/, { timeout: 10000 }); + }); + }); +}); diff --git a/frontend/e2e/students.spec.ts b/frontend/e2e/students.spec.ts index 33e9222..d96def4 100644 --- a/frontend/e2e/students.spec.ts +++ b/frontend/e2e/students.spec.ts @@ -126,7 +126,7 @@ test.describe('Student Management', () => { await expect(page).toHaveTitle(/fiche élève/i); }); - test('back link navigates to users page', async ({ page }) => { + test('back link navigates to students page', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`); @@ -136,8 +136,8 @@ test.describe('Student Management', () => { // Click the back link await page.locator('.back-link').click(); - // Should navigate to users page - await expect(page).toHaveURL(/\/admin\/users/); + // Should navigate to students page + await expect(page).toHaveURL(/\/admin\/students/); }); }); diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index e28c351..14452b8 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -36,6 +36,11 @@ Configurer les classes Créer et gérer + + 🎒 + Gérer les élèves + Inscrire et affecter + 📚 Gérer les matières diff --git a/frontend/src/lib/features/students/api/students.ts b/frontend/src/lib/features/students/api/students.ts new file mode 100644 index 0000000..0e4d382 --- /dev/null +++ b/frontend/src/lib/features/students/api/students.ts @@ -0,0 +1,150 @@ +import { getApiBaseUrl } from '$lib/api'; +import { authenticatedFetch } from '$lib/auth'; + +export interface Student { + id: string; + firstName: string; + lastName: string; + email: string | null; + classId: string | null; + className: string | null; + classLevel: string | null; + statut: string; + studentNumber: string | null; + dateNaissance: string | null; +} + +export interface SchoolClass { + id: string; + name: string; + level: string | null; +} + +export interface FetchStudentsParams { + page: number; + itemsPerPage: number; + search?: string | undefined; + classId?: string | undefined; + signal?: AbortSignal | undefined; +} + +export interface FetchStudentsResult { + members: Student[]; + totalItems: number; +} + +export interface CreateStudentData { + firstName: string; + lastName: string; + classId: string; + email?: string | undefined; + dateNaissance?: string | undefined; + studentNumber?: string | undefined; +} + +/** + * Récupère la liste paginée des élèves. + */ +export async function fetchStudents(params: FetchStudentsParams): Promise { + const apiUrl = getApiBaseUrl(); + const searchParams = new URLSearchParams(); + searchParams.set('page', String(params.page)); + searchParams.set('itemsPerPage', String(params.itemsPerPage)); + if (params.search) searchParams.set('search', params.search); + if (params.classId) searchParams.set('classId', params.classId); + + const options: RequestInit = {}; + if (params.signal) options.signal = params.signal; + + const response = await authenticatedFetch( + `${apiUrl}/students?${searchParams.toString()}`, + options + ); + + if (!response.ok) { + throw new Error('Erreur lors du chargement des élèves'); + } + + const data = await response.json(); + const members: Student[] = + data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []); + const totalItems: number = data['hydra:totalItems'] ?? data['totalItems'] ?? members.length; + + return { members, totalItems }; +} + +/** + * Récupère la liste des classes disponibles. + */ +export async function fetchClasses(): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/classes?itemsPerPage=200`); + + if (!response.ok) { + throw new Error('Erreur lors du chargement des classes'); + } + + const data = await response.json(); + return data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []); +} + +/** + * Crée un nouvel élève. + */ +export async function createStudent(studentData: CreateStudentData): Promise { + const apiUrl = getApiBaseUrl(); + const body: Record = { + firstName: studentData.firstName, + lastName: studentData.lastName, + classId: studentData.classId + }; + if (studentData.email) body['email'] = studentData.email; + if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance; + if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber; + + const response = await authenticatedFetch(`${apiUrl}/students`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + let errorMessage = `Erreur lors de la création (${response.status})`; + try { + const errorData = await response.json(); + if (errorData['hydra:description']) errorMessage = errorData['hydra:description']; + else if (errorData.message) errorMessage = errorData.message; + else if (errorData.detail) errorMessage = errorData.detail; + } catch { + // JSON parsing failed + } + throw new Error(errorMessage); + } + + return await response.json(); +} + +/** + * Change la classe d'un élève. + */ +export async function changeStudentClass(studentId: string, newClassId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/students/${studentId}/class`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/merge-patch+json' }, + body: JSON.stringify({ classId: newClassId }) + }); + + if (!response.ok) { + let errorMessage = `Erreur lors du changement de classe (${response.status})`; + try { + const errorData = await response.json(); + if (errorData['hydra:description']) errorMessage = errorData['hydra:description']; + else if (errorData.message) errorMessage = errorData.message; + else if (errorData.detail) errorMessage = errorData.detail; + } catch { + // JSON parsing failed + } + throw new Error(errorMessage); + } +} diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 3cc3ec3..1b935cc 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -25,6 +25,7 @@ const navLinks = [ { href: '/dashboard', label: 'Tableau de bord', isActive: () => false }, { href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive }, + { href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive }, { href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive }, { href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive }, { href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive }, @@ -81,6 +82,7 @@ // Determine which admin section is active const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users')); + const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students')); const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes')); const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects')); const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods')); diff --git a/frontend/src/routes/admin/students/+page.svelte b/frontend/src/routes/admin/students/+page.svelte new file mode 100644 index 0000000..4b2b547 --- /dev/null +++ b/frontend/src/routes/admin/students/+page.svelte @@ -0,0 +1,1292 @@ + + + + Gestion des élèves - Classeo + + +
+ + + {#if error} +
+ {error} + +
+ {/if} + + {#if successMessage} +
+ {successMessage} + +
+ {/if} + + +
+
+ + +
+
+ +
+
+ + + + {#if isLoading} +
+
+

Chargement des élèves...

+
+ {:else if students.length === 0} +
+ 🎓 + {#if searchTerm || filterClassId} +

Aucun résultat

+

Aucun élève ne correspond à vos critères de recherche

+ + {:else} +

Aucun élève

+

Commencez par créer votre premier élève

+ + {/if} +
+ {:else} +
+ + + + + + + + + + + {#each students as student (student.id)} + goto(`/admin/students/${student.id}`)} + role="link" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') goto(`/admin/students/${student.id}`); }} + > + + + + + + {/each} + +
NomClasseStatutActions
+ {student.lastName} {student.firstName} + + {#if student.className} + {student.className} + {:else} + Non affecté + {/if} + + + {getStatutLabel(student.statut)} + + + +
+
+ + {/if} +
+ + +{#if showCreateModal} + + +{/if} + + +{#if showChangeClassModal && changeClassTarget} + + +{/if} + + diff --git a/frontend/src/routes/admin/students/[id]/+page.svelte b/frontend/src/routes/admin/students/[id]/+page.svelte index c23807e..cd1b185 100644 --- a/frontend/src/routes/admin/students/[id]/+page.svelte +++ b/frontend/src/routes/admin/students/[id]/+page.svelte @@ -11,7 +11,7 @@
diff --git a/frontend/tests/unit/lib/features/students/api/students.test.ts b/frontend/tests/unit/lib/features/students/api/students.test.ts new file mode 100644 index 0000000..cdde565 --- /dev/null +++ b/frontend/tests/unit/lib/features/students/api/students.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('$lib/api', () => ({ + getApiBaseUrl: () => 'http://test.classeo.local:18000/api' +})); + +const mockAuthenticatedFetch = vi.fn(); +vi.mock('$lib/auth', () => ({ + authenticatedFetch: (...args: unknown[]) => mockAuthenticatedFetch(...args) +})); + +import { + fetchStudents, + fetchClasses, + createStudent, + changeStudentClass +} from '$lib/features/students/api/students'; + +describe('students API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================== + // fetchStudents + // ========================================================================== + describe('fetchStudents', () => { + it('should return members and totalItems on success', async () => { + const mockStudents = [ + { + id: 'student-1', + firstName: 'Marie', + lastName: 'Dupont', + email: null, + classId: 'class-1', + className: '6ème A', + classLevel: 'sixieme', + statut: 'inscrit', + studentNumber: null, + dateNaissance: null + } + ]; + + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + 'hydra:member': mockStudents, + 'hydra:totalItems': 1 + }) + }); + + const result = await fetchStudents({ page: 1, itemsPerPage: 30 }); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/students?page=1&itemsPerPage=30', + {} + ); + expect(result.members).toHaveLength(1); + expect(result.members[0]!.firstName).toBe('Marie'); + expect(result.totalItems).toBe(1); + }); + + it('should pass search and classId params when provided', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + 'hydra:member': [], + 'hydra:totalItems': 0 + }) + }); + + await fetchStudents({ + page: 2, + itemsPerPage: 30, + search: 'Dupont', + classId: 'class-1' + }); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/students?page=2&itemsPerPage=30&search=Dupont&classId=class-1', + {} + ); + }); + + it('should throw when API response is not ok', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(fetchStudents({ page: 1, itemsPerPage: 30 })).rejects.toThrow( + 'Erreur lors du chargement des élèves' + ); + }); + }); + + // ========================================================================== + // fetchClasses + // ========================================================================== + describe('fetchClasses', () => { + it('should return classes array on success', async () => { + const mockClasses = [ + { id: 'class-1', name: '6ème A', level: 'sixieme' }, + { id: 'class-2', name: '5ème B', level: 'cinquieme' } + ]; + + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ 'hydra:member': mockClasses }) + }); + + const result = await fetchClasses(); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/classes?itemsPerPage=200' + ); + expect(result).toHaveLength(2); + expect(result[0]!.name).toBe('6ème A'); + }); + + it('should throw when API response is not ok', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(fetchClasses()).rejects.toThrow('Erreur lors du chargement des classes'); + }); + }); + + // ========================================================================== + // createStudent + // ========================================================================== + describe('createStudent', () => { + it('should return created student on success', async () => { + const created = { + id: 'new-student-id', + firstName: 'Marie', + lastName: 'Dupont', + email: null, + classId: 'class-1', + className: '6ème A', + classLevel: 'sixieme', + statut: 'inscrit', + studentNumber: null, + dateNaissance: null + }; + + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(created) + }); + + const result = await createStudent({ + firstName: 'Marie', + lastName: 'Dupont', + classId: 'class-1' + }); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/students', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + firstName: 'Marie', + lastName: 'Dupont', + classId: 'class-1' + }) + }) + ); + expect(result.id).toBe('new-student-id'); + expect(result.firstName).toBe('Marie'); + }); + + it('should include optional fields when provided', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: 'new-id', + firstName: 'Marie', + lastName: 'Dupont', + email: 'marie@example.com', + classId: 'class-1', + className: '6ème A', + classLevel: 'sixieme', + statut: 'pending', + studentNumber: '12345', + dateNaissance: '2015-06-15' + }) + }); + + await createStudent({ + firstName: 'Marie', + lastName: 'Dupont', + classId: 'class-1', + email: 'marie@example.com', + dateNaissance: '2015-06-15', + studentNumber: '12345' + }); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/students', + expect.objectContaining({ + body: JSON.stringify({ + firstName: 'Marie', + lastName: 'Dupont', + classId: 'class-1', + email: 'marie@example.com', + dateNaissance: '2015-06-15', + studentNumber: '12345' + }) + }) + ); + }); + + it('should throw with hydra:description on error', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + json: () => + Promise.resolve({ + 'hydra:description': 'Cet email est déjà utilisé.' + }) + }); + + await expect( + createStudent({ firstName: 'Marie', lastName: 'Dupont', classId: 'class-1' }) + ).rejects.toThrow('Cet email est déjà utilisé.'); + }); + + it('should throw generic message when error body is not valid JSON', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.reject(new Error('Unexpected token')) + }); + + await expect( + createStudent({ firstName: 'Marie', lastName: 'Dupont', classId: 'class-1' }) + ).rejects.toThrow('Erreur lors de la création (500)'); + }); + }); + + // ========================================================================== + // changeStudentClass + // ========================================================================== + describe('changeStudentClass', () => { + it('should call PATCH endpoint with correct body', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true + }); + + await changeStudentClass('student-1', 'class-2'); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/students/student-1/class', + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/merge-patch+json' }, + body: JSON.stringify({ classId: 'class-2' }) + }) + ); + }); + + it('should throw with hydra:description on error', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: () => + Promise.resolve({ + 'hydra:description': 'Élève non trouvé.' + }) + }); + + await expect(changeStudentClass('student-1', 'class-2')).rejects.toThrow( + 'Élève non trouvé.' + ); + }); + + it('should throw generic message when error body is not valid JSON', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.reject(new Error('Unexpected token')) + }); + + await expect(changeStudentClass('student-1', 'class-2')).rejects.toThrow( + 'Erreur lors du changement de classe (500)' + ); + }); + }); +});