diff --git a/backend/config/services.yaml b/backend/config/services.yaml index d6d477f..094fbc1 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -149,6 +149,13 @@ services: App\Administration\Domain\Repository\GradingConfigurationRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository + # Teacher Assignment (Story 2.8 - Affectation enseignants) + App\Administration\Domain\Repository\TeacherAssignmentRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherAssignmentRepository + + App\Administration\Application\Port\TeacherAssignmentChecker: + alias: App\Administration\Infrastructure\Service\RepositoryTeacherAssignmentChecker + # Student Guardian Repository (Story 2.7 - Liaison parents-enfants) App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository: arguments: diff --git a/backend/migrations/Version20260212100000.php b/backend/migrations/Version20260212100000.php new file mode 100644 index 0000000..9678083 --- /dev/null +++ b/backend/migrations/Version20260212100000.php @@ -0,0 +1,55 @@ +addSql(<<<'SQL' + CREATE TABLE IF NOT EXISTS teacher_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + teacher_id UUID NOT NULL, + school_class_id UUID NOT NULL, + subject_id UUID NOT NULL, + academic_year_id UUID NOT NULL, + start_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + end_date TIMESTAMPTZ, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(teacher_id, school_class_id, subject_id, academic_year_id) + ) + SQL); + + $this->addSql('CREATE INDEX idx_teacher_assignments_tenant ON teacher_assignments(tenant_id)'); + $this->addSql('CREATE INDEX idx_teacher_assignments_teacher ON teacher_assignments(teacher_id)'); + $this->addSql('CREATE INDEX idx_teacher_assignments_class ON teacher_assignments(school_class_id)'); + $this->addSql('CREATE INDEX idx_teacher_assignments_year ON teacher_assignments(academic_year_id)'); + $this->addSql('CREATE INDEX idx_teacher_assignments_teacher_tenant_status ON teacher_assignments(teacher_id, tenant_id, status)'); + $this->addSql('CREATE INDEX idx_teacher_assignments_class_tenant_status ON teacher_assignments(school_class_id, tenant_id, status)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS teacher_assignments'); + } +} diff --git a/backend/migrations/Version20260212200000.php b/backend/migrations/Version20260212200000.php new file mode 100644 index 0000000..e5af657 --- /dev/null +++ b/backend/migrations/Version20260212200000.php @@ -0,0 +1,52 @@ +addSql(<<<'SQL' + ALTER TABLE teacher_assignments + DROP CONSTRAINT IF EXISTS teacher_assignments_teacher_id_school_class_id_subject_id_acad_key + SQL); + + $this->addSql(<<<'SQL' + ALTER TABLE teacher_assignments + ADD CONSTRAINT teacher_assignments_tenant_teacher_class_subject_year_key + UNIQUE (tenant_id, teacher_id, school_class_id, subject_id, academic_year_id) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE teacher_assignments + DROP CONSTRAINT IF EXISTS teacher_assignments_tenant_teacher_class_subject_year_key + SQL); + + $this->addSql(<<<'SQL' + ALTER TABLE teacher_assignments + ADD CONSTRAINT teacher_assignments_teacher_id_school_class_id_subject_id_acad_key + UNIQUE (teacher_id, school_class_id, subject_id, academic_year_id) + SQL); + } +} diff --git a/backend/src/Administration/Application/Command/AssignTeacher/AssignTeacherCommand.php b/backend/src/Administration/Application/Command/AssignTeacher/AssignTeacherCommand.php new file mode 100644 index 0000000..d7fe4fa --- /dev/null +++ b/backend/src/Administration/Application/Command/AssignTeacher/AssignTeacherCommand.php @@ -0,0 +1,17 @@ +tenantId); + $teacherId = UserId::fromString($command->teacherId); + $classId = ClassId::fromString($command->classId); + $subjectId = SubjectId::fromString($command->subjectId); + $academicYearId = AcademicYearId::fromString($command->academicYearId); + + // Valider l'existence des entités référencées (throws NotFoundException) + $this->userRepository->get($teacherId); + $this->classRepository->get($classId); + $this->subjectRepository->get($subjectId); + + // Vérifier l'unicité du triplet enseignant × classe × matière + $existing = $this->assignmentRepository->findByTeacherClassSubject( + $teacherId, + $classId, + $subjectId, + $academicYearId, + $tenantId, + ); + + if ($existing !== null) { + throw AffectationDejaExistanteException::pourTriple($teacherId, $classId, $subjectId); + } + + // Vérifier si une affectation retirée existe pour ce même triplet. + // Si oui, la réactiver au lieu d'en créer une nouvelle (évite la violation + // de la contrainte UNIQUE qui couvre tous les statuts). + $removed = $this->assignmentRepository->findRemovedByTeacherClassSubject( + $teacherId, + $classId, + $subjectId, + $academicYearId, + $tenantId, + ); + + if ($removed !== null) { + $removed->reactiver($this->clock->now()); + $this->assignmentRepository->save($removed); + + return $removed; + } + + $assignment = TeacherAssignment::creer( + tenantId: $tenantId, + teacherId: $teacherId, + classId: $classId, + subjectId: $subjectId, + academicYearId: $academicYearId, + createdAt: $this->clock->now(), + ); + + $this->assignmentRepository->save($assignment); + + return $assignment; + } +} diff --git a/backend/src/Administration/Application/Command/RemoveAssignment/RemoveAssignmentCommand.php b/backend/src/Administration/Application/Command/RemoveAssignment/RemoveAssignmentCommand.php new file mode 100644 index 0000000..72be7d6 --- /dev/null +++ b/backend/src/Administration/Application/Command/RemoveAssignment/RemoveAssignmentCommand.php @@ -0,0 +1,14 @@ +assignmentId); + $tenantId = TenantId::fromString($command->tenantId); + + $assignment = $this->assignmentRepository->get($assignmentId, $tenantId); + + $assignment->retirer($this->clock->now()); + + $this->assignmentRepository->save($assignment); + + return $assignment; + } +} diff --git a/backend/src/Administration/Application/Port/TeacherAssignmentChecker.php b/backend/src/Administration/Application/Port/TeacherAssignmentChecker.php new file mode 100644 index 0000000..208b65e --- /dev/null +++ b/backend/src/Administration/Application/Port/TeacherAssignmentChecker.php @@ -0,0 +1,31 @@ +assignmentRepository->findByTeacherClassSubject( + UserId::fromString($query->teacherId), + ClassId::fromString($query->classId), + SubjectId::fromString($query->subjectId), + AcademicYearId::fromString($query->academicYearId), + TenantId::fromString($query->tenantId), + ); + + if ($assignment === null) { + return null; + } + + return TeacherAssignmentDto::fromDomain($assignment); + } +} diff --git a/backend/src/Administration/Application/Query/GetAssignment/GetAssignmentQuery.php b/backend/src/Administration/Application/Query/GetAssignment/GetAssignmentQuery.php new file mode 100644 index 0000000..8f993a5 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetAssignment/GetAssignmentQuery.php @@ -0,0 +1,17 @@ +teacherId); + $tenantId = TenantId::fromString($query->tenantId); + + $assignments = $this->assignmentRepository->findActiveByTeacher($teacherId, $tenantId); + + return array_map(TeacherAssignmentDto::fromDomain(...), $assignments); + } +} diff --git a/backend/src/Administration/Application/Query/GetAssignmentsForTeacher/GetAssignmentsForTeacherQuery.php b/backend/src/Administration/Application/Query/GetAssignmentsForTeacher/GetAssignmentsForTeacherQuery.php new file mode 100644 index 0000000..bbea3e9 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetAssignmentsForTeacher/GetAssignmentsForTeacherQuery.php @@ -0,0 +1,14 @@ +id, + teacherId: (string) $assignment->teacherId, + classId: (string) $assignment->classId, + subjectId: (string) $assignment->subjectId, + academicYearId: (string) $assignment->academicYearId, + status: $assignment->status->value, + startDate: $assignment->startDate, + endDate: $assignment->endDate, + createdAt: $assignment->createdAt, + ); + } +} diff --git a/backend/src/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassHandler.php b/backend/src/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassHandler.php new file mode 100644 index 0000000..93610a1 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassHandler.php @@ -0,0 +1,33 @@ +classId); + $tenantId = TenantId::fromString($query->tenantId); + + $assignments = $this->assignmentRepository->findActiveByClass($classId, $tenantId); + + return array_map(TeacherAssignmentDto::fromDomain(...), $assignments); + } +} diff --git a/backend/src/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassQuery.php b/backend/src/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassQuery.php new file mode 100644 index 0000000..8c230dd --- /dev/null +++ b/backend/src/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassQuery.php @@ -0,0 +1,14 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->assignmentId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/EnseignantAffecte.php b/backend/src/Administration/Domain/Event/EnseignantAffecte.php new file mode 100644 index 0000000..bd4ac30 --- /dev/null +++ b/backend/src/Administration/Domain/Event/EnseignantAffecte.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->assignmentId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/AffectationDejaExistanteException.php b/backend/src/Administration/Domain/Exception/AffectationDejaExistanteException.php new file mode 100644 index 0000000..f22281e --- /dev/null +++ b/backend/src/Administration/Domain/Exception/AffectationDejaExistanteException.php @@ -0,0 +1,25 @@ + 'Active', + self::REMOVED => 'Retirée', + }; + } +} diff --git a/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignment.php b/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignment.php new file mode 100644 index 0000000..3f5df07 --- /dev/null +++ b/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignment.php @@ -0,0 +1,166 @@ +updatedAt = $createdAt; + } + + public static function creer( + TenantId $tenantId, + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + AcademicYearId $academicYearId, + DateTimeImmutable $createdAt, + ): self { + $assignment = new self( + id: TeacherAssignmentId::generate(), + tenantId: $tenantId, + teacherId: $teacherId, + classId: $classId, + subjectId: $subjectId, + academicYearId: $academicYearId, + startDate: $createdAt, + endDate: null, + status: AssignmentStatus::ACTIVE, + createdAt: $createdAt, + ); + + $assignment->recordEvent(new EnseignantAffecte( + assignmentId: $assignment->id, + teacherId: $assignment->teacherId, + classId: $assignment->classId, + subjectId: $assignment->subjectId, + occurredOn: $createdAt, + )); + + return $assignment; + } + + /** + * Retire l'affectation. Les notes existantes sont conservées (historique), + * mais l'enseignant ne peut plus en ajouter. + */ + public function retirer(DateTimeImmutable $at): void + { + if ($this->status === AssignmentStatus::REMOVED) { + return; + } + + $this->status = AssignmentStatus::REMOVED; + $this->endDate = $at; + $this->updatedAt = $at; + + $this->recordEvent(new AffectationRetiree( + assignmentId: $this->id, + teacherId: $this->teacherId, + classId: $this->classId, + subjectId: $this->subjectId, + occurredOn: $at, + )); + } + + /** + * Réactive une affectation précédemment retirée. + * + * Permet de ré-affecter un enseignant au même triplet sans violer + * la contrainte d'unicité en base. + */ + public function reactiver(DateTimeImmutable $at): void + { + if ($this->status === AssignmentStatus::ACTIVE) { + return; + } + + $this->status = AssignmentStatus::ACTIVE; + $this->endDate = null; + $this->startDate = $at; + $this->updatedAt = $at; + + $this->recordEvent(new EnseignantAffecte( + assignmentId: $this->id, + teacherId: $this->teacherId, + classId: $this->classId, + subjectId: $this->subjectId, + occurredOn: $at, + )); + } + + public function estActive(): bool + { + return $this->status->estActive(); + } + + /** + * Reconstitue une TeacherAssignment depuis le stockage. + * + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + TeacherAssignmentId $id, + TenantId $tenantId, + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + AcademicYearId $academicYearId, + DateTimeImmutable $startDate, + ?DateTimeImmutable $endDate, + AssignmentStatus $status, + DateTimeImmutable $createdAt, + DateTimeImmutable $updatedAt, + ): self { + $assignment = new self( + id: $id, + tenantId: $tenantId, + teacherId: $teacherId, + classId: $classId, + subjectId: $subjectId, + academicYearId: $academicYearId, + startDate: $startDate, + endDate: $endDate, + status: $status, + createdAt: $createdAt, + ); + + $assignment->updatedAt = $updatedAt; + + return $assignment; + } +} diff --git a/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignmentId.php b/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignmentId.php new file mode 100644 index 0000000..94454c6 --- /dev/null +++ b/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignmentId.php @@ -0,0 +1,11 @@ +tenantContext->getCurrentTenantId(); - // TODO: Récupérer school_id et academic_year_id depuis le contexte utilisateur - // quand les modules Schools et AcademicYears seront implémentés. - // Pour l'instant, on utilise des UUIDs déterministes basés sur le tenant. + // TODO: Récupérer school_id depuis le contexte utilisateur + // quand le module Schools sera implémenté. $schoolId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString(); - $academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString(); + $academicYearId = $this->academicYearResolver->resolve('current') + ?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.'); try { $command = new CreateClassCommand( diff --git a/backend/src/Administration/Infrastructure/Api/Processor/CreateTeacherAssignmentProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/CreateTeacherAssignmentProcessor.php new file mode 100644 index 0000000..a4a4340 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/CreateTeacherAssignmentProcessor.php @@ -0,0 +1,88 @@ + + */ +final readonly class CreateTeacherAssignmentProcessor implements ProcessorInterface +{ + public function __construct( + private AssignTeacherHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + ) { + } + + /** + * @param TeacherAssignmentResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TeacherAssignmentResource + { + if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::CREATE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer une affectation.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + $rawAcademicYearId = $this->academicYearResolver->resolve($data->academicYearId ?? 'current'); + if ($rawAcademicYearId === null) { + throw new BadRequestHttpException('Identifiant d\'année scolaire invalide.'); + } + + try { + $command = new AssignTeacherCommand( + tenantId: $tenantId, + teacherId: $data->teacherId ?? '', + classId: $data->classId ?? '', + subjectId: $data->subjectId ?? '', + academicYearId: $rawAcademicYearId, + ); + + $assignment = ($this->handler)($command); + + foreach ($assignment->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return TeacherAssignmentResource::fromDomain($assignment); + } catch (AffectationDejaExistanteException $e) { + throw new ConflictHttpException($e->getMessage()); + } catch (UserNotFoundException|ClasseNotFoundException|SubjectNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (InvalidUuidStringException $e) { + throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/RemoveTeacherAssignmentProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/RemoveTeacherAssignmentProcessor.php new file mode 100644 index 0000000..f08f60c --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/RemoveTeacherAssignmentProcessor.php @@ -0,0 +1,75 @@ + + */ +final readonly class RemoveTeacherAssignmentProcessor implements ProcessorInterface +{ + public function __construct( + private RemoveAssignmentHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @param TeacherAssignmentResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null + { + if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::DELETE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à retirer une affectation.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string|null $assignmentId */ + $assignmentId = $uriVariables['id'] ?? null; + if ($assignmentId === null) { + throw new NotFoundHttpException('Affectation non trouvée.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + try { + $command = new RemoveAssignmentCommand( + assignmentId: $assignmentId, + tenantId: $tenantId, + ); + + $assignment = ($this->handler)($command); + + foreach ($assignment->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return null; + } catch (AffectationNotFoundException|InvalidUuidStringException) { + throw new NotFoundHttpException('Affectation non trouvée.'); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php index 673e164..a50aac5 100644 --- a/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php +++ b/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php @@ -10,9 +10,9 @@ use App\Administration\Application\Query\GetClasses\GetClassesHandler; use App\Administration\Application\Query\GetClasses\GetClassesQuery; use App\Administration\Infrastructure\Api\Resource\ClassResource; use App\Administration\Infrastructure\Security\ClassVoter; +use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver; use App\Shared\Infrastructure\Tenant\TenantContext; use Override; -use Ramsey\Uuid\Uuid; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; @@ -28,6 +28,7 @@ final readonly class ClassCollectionProvider implements ProviderInterface private GetClassesHandler $handler, private TenantContext $tenantContext, private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, ) { } @@ -48,9 +49,10 @@ final readonly class ClassCollectionProvider implements ProviderInterface $tenantId = (string) $this->tenantContext->getCurrentTenantId(); - // TODO: Récupérer academic_year_id depuis le contexte utilisateur - // quand le module AcademicYears sera implémenté. - $academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString(); + $academicYearId = $this->academicYearResolver->resolve('current') ?? ''; + if ($academicYearId === '') { + return []; + } $query = new GetClassesQuery( tenantId: $tenantId, diff --git a/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentItemProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentItemProvider.php new file mode 100644 index 0000000..d2aee79 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentItemProvider.php @@ -0,0 +1,61 @@ + + */ +final readonly class TeacherAssignmentItemProvider implements ProviderInterface +{ + public function __construct( + private TeacherAssignmentRepository $repository, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?TeacherAssignmentResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::DELETE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer cette affectation.'); + } + + /** @var string $id */ + $id = $uriVariables['id'] ?? ''; + + try { + $assignment = $this->repository->findById( + TeacherAssignmentId::fromString($id), + $this->tenantContext->getCurrentTenantId(), + ); + } catch (InvalidArgumentException) { + return null; + } + + if ($assignment === null) { + return null; + } + + return TeacherAssignmentResource::fromDomain($assignment); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByClassProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByClassProvider.php new file mode 100644 index 0000000..5976565 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByClassProvider.php @@ -0,0 +1,67 @@ + + */ +final readonly class TeacherAssignmentsByClassProvider implements ProviderInterface +{ + public function __construct( + private GetTeachersForClassHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @return TeacherAssignmentResource[] + */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::VIEW)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les enseignants de cette classe.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $classId */ + $classId = $uriVariables['classId'] ?? ''; + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + try { + $query = new GetTeachersForClassQuery( + classId: $classId, + tenantId: $tenantId, + ); + + $dtos = ($this->handler)($query); + + return array_map(TeacherAssignmentResource::fromDto(...), $dtos); + } catch (InvalidUuidStringException $e) { + throw new BadRequestHttpException('Identifiant classe invalide.', $e); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByTeacherProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByTeacherProvider.php new file mode 100644 index 0000000..8a70664 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByTeacherProvider.php @@ -0,0 +1,72 @@ + + */ +final readonly class TeacherAssignmentsByTeacherProvider implements ProviderInterface +{ + public function __construct( + private GetAssignmentsForTeacherHandler $handler, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @return TeacherAssignmentResource[] + */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + /** @var string $teacherId */ + $teacherId = $uriVariables['teacherId'] ?? ''; + + // Passer une ressource avec le teacherId pour que le voter puisse + // vérifier que l'enseignant ne consulte que ses propres affectations. + $subjectForVoter = new TeacherAssignmentResource(); + $subjectForVoter->teacherId = $teacherId; + + if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::VIEW, $subjectForVoter)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les affectations.'); + } + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + + try { + $query = new GetAssignmentsForTeacherQuery( + teacherId: $teacherId, + tenantId: $tenantId, + ); + + $dtos = ($this->handler)($query); + + return array_map(TeacherAssignmentResource::fromDto(...), $dtos); + } catch (InvalidUuidStringException $e) { + throw new BadRequestHttpException('Identifiant enseignant invalide.', $e); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/TeacherAssignmentResource.php b/backend/src/Administration/Infrastructure/Api/Resource/TeacherAssignmentResource.php new file mode 100644 index 0000000..4f8013c --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/TeacherAssignmentResource.php @@ -0,0 +1,124 @@ + new Link( + fromClass: self::class, + identifiers: ['teacherId'], + ), + ], + provider: TeacherAssignmentsByTeacherProvider::class, + name: 'get_teacher_assignments', + ), + new GetCollection( + uriTemplate: '/classes/{classId}/teachers', + uriVariables: [ + 'classId' => new Link( + fromClass: self::class, + identifiers: ['classId'], + ), + ], + provider: TeacherAssignmentsByClassProvider::class, + name: 'get_class_teachers', + ), + new Post( + uriTemplate: '/teacher-assignments', + processor: CreateTeacherAssignmentProcessor::class, + validationContext: ['groups' => ['Default', 'create']], + name: 'create_teacher_assignment', + ), + new Delete( + uriTemplate: '/teacher-assignments/{id}', + provider: TeacherAssignmentItemProvider::class, + processor: RemoveTeacherAssignmentProcessor::class, + name: 'remove_teacher_assignment', + ), + ], +)] +final class TeacherAssignmentResource +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + #[Assert\NotBlank(message: 'L\'identifiant de l\'enseignant est requis.', groups: ['create'])] + public ?string $teacherId = null; + + #[Assert\NotBlank(message: 'L\'identifiant de la classe est requis.', groups: ['create'])] + public ?string $classId = null; + + #[Assert\NotBlank(message: 'L\'identifiant de la matière est requis.', groups: ['create'])] + public ?string $subjectId = null; + + #[Assert\NotBlank(message: 'L\'identifiant de l\'année scolaire est requis.', groups: ['create'])] + public ?string $academicYearId = null; + + public ?string $status = null; + + public ?DateTimeImmutable $startDate = null; + + public ?DateTimeImmutable $endDate = null; + + public ?DateTimeImmutable $createdAt = null; + + public static function fromDomain(TeacherAssignment $assignment): self + { + $resource = new self(); + $resource->id = (string) $assignment->id; + $resource->teacherId = (string) $assignment->teacherId; + $resource->classId = (string) $assignment->classId; + $resource->subjectId = (string) $assignment->subjectId; + $resource->academicYearId = (string) $assignment->academicYearId; + $resource->status = $assignment->status->value; + $resource->startDate = $assignment->startDate; + $resource->endDate = $assignment->endDate; + $resource->createdAt = $assignment->createdAt; + + return $resource; + } + + public static function fromDto(TeacherAssignmentDto $dto): self + { + $resource = new self(); + $resource->id = $dto->id; + $resource->teacherId = $dto->teacherId; + $resource->classId = $dto->classId; + $resource->subjectId = $dto->subjectId; + $resource->academicYearId = $dto->academicYearId; + $resource->status = $dto->status; + $resource->startDate = $dto->startDate; + $resource->endDate = $dto->endDate; + $resource->createdAt = $dto->createdAt; + + return $resource; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherAssignmentRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherAssignmentRepository.php new file mode 100644 index 0000000..9202a63 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherAssignmentRepository.php @@ -0,0 +1,242 @@ +connection->executeStatement( + 'INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, start_date, end_date, status, created_at, updated_at) + VALUES (:id, :tenant_id, :teacher_id, :school_class_id, :subject_id, :academic_year_id, :start_date, :end_date, :status, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET + start_date = EXCLUDED.start_date, + end_date = EXCLUDED.end_date, + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $assignment->id, + 'tenant_id' => (string) $assignment->tenantId, + 'teacher_id' => (string) $assignment->teacherId, + 'school_class_id' => (string) $assignment->classId, + 'subject_id' => (string) $assignment->subjectId, + 'academic_year_id' => (string) $assignment->academicYearId, + 'start_date' => $assignment->startDate->format(DateTimeImmutable::ATOM), + 'end_date' => $assignment->endDate?->format(DateTimeImmutable::ATOM), + 'status' => $assignment->status->value, + 'created_at' => $assignment->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $assignment->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } catch (UniqueConstraintViolationException) { + throw AffectationDejaExistanteException::pourTriple( + $assignment->teacherId, + $assignment->classId, + $assignment->subjectId, + ); + } + } + + #[Override] + public function get(TeacherAssignmentId $id, TenantId $tenantId): TeacherAssignment + { + $assignment = $this->findById($id, $tenantId); + + if ($assignment === null) { + throw AffectationNotFoundException::withId($id); + } + + return $assignment; + } + + #[Override] + public function findById(TeacherAssignmentId $id, TenantId $tenantId): ?TeacherAssignment + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM teacher_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 findByTeacherClassSubject( + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + AcademicYearId $academicYearId, + TenantId $tenantId, + ): ?TeacherAssignment { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM teacher_assignments + WHERE tenant_id = :tenant_id + AND teacher_id = :teacher_id + AND school_class_id = :school_class_id + AND subject_id = :subject_id + AND academic_year_id = :academic_year_id + AND status = :status', + [ + 'tenant_id' => (string) $tenantId, + 'teacher_id' => (string) $teacherId, + 'school_class_id' => (string) $classId, + 'subject_id' => (string) $subjectId, + 'academic_year_id' => (string) $academicYearId, + 'status' => AssignmentStatus::ACTIVE->value, + ], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findRemovedByTeacherClassSubject( + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + AcademicYearId $academicYearId, + TenantId $tenantId, + ): ?TeacherAssignment { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM teacher_assignments + WHERE tenant_id = :tenant_id + AND teacher_id = :teacher_id + AND school_class_id = :school_class_id + AND subject_id = :subject_id + AND academic_year_id = :academic_year_id + AND status = :status', + [ + 'tenant_id' => (string) $tenantId, + 'teacher_id' => (string) $teacherId, + 'school_class_id' => (string) $classId, + 'subject_id' => (string) $subjectId, + 'academic_year_id' => (string) $academicYearId, + 'status' => AssignmentStatus::REMOVED->value, + ], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findActiveByTeacher( + UserId $teacherId, + TenantId $tenantId, + ): array { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM teacher_assignments + WHERE teacher_id = :teacher_id + AND tenant_id = :tenant_id + AND status = :status + ORDER BY created_at ASC', + [ + 'teacher_id' => (string) $teacherId, + 'tenant_id' => (string) $tenantId, + 'status' => AssignmentStatus::ACTIVE->value, + ], + ); + + return array_map(fn ($row) => $this->hydrate($row), $rows); + } + + #[Override] + public function findActiveByClass( + ClassId $classId, + TenantId $tenantId, + ): array { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM teacher_assignments + WHERE school_class_id = :school_class_id + AND tenant_id = :tenant_id + AND status = :status + ORDER BY created_at ASC', + [ + 'school_class_id' => (string) $classId, + 'tenant_id' => (string) $tenantId, + 'status' => AssignmentStatus::ACTIVE->value, + ], + ); + + return array_map(fn ($row) => $this->hydrate($row), $rows); + } + + /** + * @param array $row + */ + private function hydrate(array $row): TeacherAssignment + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $teacherId */ + $teacherId = $row['teacher_id']; + /** @var string $classId */ + $classId = $row['school_class_id']; + /** @var string $subjectId */ + $subjectId = $row['subject_id']; + /** @var string $academicYearId */ + $academicYearId = $row['academic_year_id']; + /** @var string $startDate */ + $startDate = $row['start_date']; + /** @var string|null $endDate */ + $endDate = $row['end_date']; + /** @var string $status */ + $status = $row['status']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return TeacherAssignment::reconstitute( + id: TeacherAssignmentId::fromString($id), + tenantId: TenantId::fromString($tenantId), + teacherId: UserId::fromString($teacherId), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString($subjectId), + academicYearId: AcademicYearId::fromString($academicYearId), + startDate: new DateTimeImmutable($startDate), + endDate: $endDate !== null ? new DateTimeImmutable($endDate) : null, + status: AssignmentStatus::from($status), + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepository.php new file mode 100644 index 0000000..b4a200c --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepository.php @@ -0,0 +1,137 @@ + */ + private array $byId = []; + + #[Override] + public function save(TeacherAssignment $assignment): void + { + $this->byId[(string) $assignment->id] = $assignment; + } + + #[Override] + public function get(TeacherAssignmentId $id, TenantId $tenantId): TeacherAssignment + { + $assignment = $this->findById($id, $tenantId); + + if ($assignment === null) { + throw AffectationNotFoundException::withId($id); + } + + return $assignment; + } + + #[Override] + public function findById(TeacherAssignmentId $id, TenantId $tenantId): ?TeacherAssignment + { + $assignment = $this->byId[(string) $id] ?? null; + + if ($assignment !== null && !$assignment->tenantId->equals($tenantId)) { + return null; + } + + return $assignment; + } + + #[Override] + public function findByTeacherClassSubject( + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + AcademicYearId $academicYearId, + TenantId $tenantId, + ): ?TeacherAssignment { + foreach ($this->byId as $assignment) { + if ($assignment->tenantId->equals($tenantId) + && $assignment->teacherId->equals($teacherId) + && $assignment->classId->equals($classId) + && $assignment->subjectId->equals($subjectId) + && $assignment->academicYearId->equals($academicYearId) + && $assignment->status === AssignmentStatus::ACTIVE + ) { + return $assignment; + } + } + + return null; + } + + #[Override] + public function findRemovedByTeacherClassSubject( + UserId $teacherId, + ClassId $classId, + SubjectId $subjectId, + AcademicYearId $academicYearId, + TenantId $tenantId, + ): ?TeacherAssignment { + foreach ($this->byId as $assignment) { + if ($assignment->tenantId->equals($tenantId) + && $assignment->teacherId->equals($teacherId) + && $assignment->classId->equals($classId) + && $assignment->subjectId->equals($subjectId) + && $assignment->academicYearId->equals($academicYearId) + && $assignment->status === AssignmentStatus::REMOVED + ) { + return $assignment; + } + } + + return null; + } + + #[Override] + public function findActiveByTeacher( + UserId $teacherId, + TenantId $tenantId, + ): array { + $result = []; + + foreach ($this->byId as $assignment) { + if ($assignment->teacherId->equals($teacherId) + && $assignment->tenantId->equals($tenantId) + && $assignment->status === AssignmentStatus::ACTIVE + ) { + $result[] = $assignment; + } + } + + return $result; + } + + #[Override] + public function findActiveByClass( + ClassId $classId, + TenantId $tenantId, + ): array { + $result = []; + + foreach ($this->byId as $assignment) { + if ($assignment->classId->equals($classId) + && $assignment->tenantId->equals($tenantId) + && $assignment->status === AssignmentStatus::ACTIVE + ) { + $result[] = $assignment; + } + } + + return $result; + } +} diff --git a/backend/src/Administration/Infrastructure/Security/TeacherAssignmentVoter.php b/backend/src/Administration/Infrastructure/Security/TeacherAssignmentVoter.php new file mode 100644 index 0000000..da2d29f --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/TeacherAssignmentVoter.php @@ -0,0 +1,150 @@ + + */ +final class TeacherAssignmentVoter extends Voter +{ + public const string VIEW = 'TEACHER_ASSIGNMENT_VIEW'; + public const string CREATE = 'TEACHER_ASSIGNMENT_CREATE'; + public const string DELETE = 'TEACHER_ASSIGNMENT_DELETE'; + + private const array SUPPORTED_ATTRIBUTES = [ + self::VIEW, + self::CREATE, + self::DELETE, + ]; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) { + return false; + } + + if ($subject === null) { + return true; + } + + return $subject instanceof TeacherAssignment || $subject instanceof TeacherAssignmentResource; + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof SecurityUser) { + return false; + } + + $roles = $user->getRoles(); + + return match ($attribute) { + self::VIEW => $this->canView($roles, $user, $subject), + self::CREATE => $this->canCreate($roles), + self::DELETE => $this->canDelete($roles), + default => false, + }; + } + + /** + * @param string[] $roles + */ + private function canView(array $roles, SecurityUser $user, mixed $subject): bool + { + // Admins et personnel administratif : accès complet en lecture + if ($this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + Role::VIE_SCOLAIRE->value, + Role::SECRETARIAT->value, + ])) { + return true; + } + + // Enseignant : lecture seule de ses propres affectations + if ($this->hasAnyRole($roles, [Role::PROF->value])) { + return $this->isOwnResource($user, $subject); + } + + return false; + } + + /** + * Vérifie que la ressource appartient à l'enseignant connecté. + */ + private function isOwnResource(SecurityUser $user, mixed $subject): bool + { + if ($subject instanceof TeacherAssignment) { + return (string) $subject->teacherId === $user->userId(); + } + + if ($subject instanceof TeacherAssignmentResource) { + return $subject->teacherId === $user->userId(); + } + + // Pas de sujet (collection sans filtre) : refuser par défaut + return false; + } + + /** + * @param string[] $roles + */ + private function canCreate(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + ]); + } + + /** + * @param string[] $roles + */ + private function canDelete(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + ]); + } + + /** + * @param string[] $userRoles + * @param string[] $allowedRoles + */ + private function hasAnyRole(array $userRoles, array $allowedRoles): bool + { + foreach ($userRoles as $role) { + if (in_array($role, $allowedRoles, true)) { + return true; + } + } + + return false; + } +} diff --git a/backend/src/Administration/Infrastructure/Service/RepositoryTeacherAssignmentChecker.php b/backend/src/Administration/Infrastructure/Service/RepositoryTeacherAssignmentChecker.php new file mode 100644 index 0000000..bf87032 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Service/RepositoryTeacherAssignmentChecker.php @@ -0,0 +1,41 @@ +assignmentRepository->findByTeacherClassSubject( + $teacherId, + $classId, + $subjectId, + $academicYearId, + $tenantId, + ); + + return $assignment !== null; + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/AssignTeacher/AssignTeacherHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/AssignTeacher/AssignTeacherHandlerTest.php new file mode 100644 index 0000000..8f846e8 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/AssignTeacher/AssignTeacherHandlerTest.php @@ -0,0 +1,286 @@ +assignmentRepository = new InMemoryTeacherAssignmentRepository(); + $this->userRepository = new InMemoryUserRepository(); + $this->classRepository = new InMemoryClassRepository(); + $this->subjectRepository = new InMemorySubjectRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-12 10:00:00'); + } + }; + + $this->seedTestData(); + } + + #[Test] + public function itCreatesAssignmentSuccessfully(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(); + + $assignment = $handler($command); + + self::assertNotEmpty((string) $assignment->id); + self::assertSame(AssignmentStatus::ACTIVE, $assignment->status); + self::assertTrue($assignment->estActive()); + self::assertNull($assignment->endDate); + } + + #[Test] + public function itPersistsAssignmentInRepository(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(); + + $created = $handler($command); + + $assignment = $this->assignmentRepository->get( + TeacherAssignmentId::fromString((string) $created->id), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertSame(AssignmentStatus::ACTIVE, $assignment->status); + } + + #[Test] + public function itThrowsExceptionWhenDuplicateAssignment(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(); + + $handler($command); + + $this->expectException(AffectationDejaExistanteException::class); + $handler($command); + } + + #[Test] + public function itAllowsSameTeacherDifferentSubject(): void + { + $secondSubjectId = '550e8400-e29b-41d4-a716-446655440031'; + $subject2 = Subject::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + name: new SubjectName('Français'), + code: new SubjectCode('FR'), + color: null, + createdAt: new DateTimeImmutable('2026-01-15'), + ); + // Reconstitute with known ID for test predictability + $subject2 = Subject::reconstitute( + id: \App\Administration\Domain\Model\Subject\SubjectId::fromString($secondSubjectId), + tenantId: TenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + name: new SubjectName('Français'), + code: new SubjectCode('FR'), + color: null, + status: \App\Administration\Domain\Model\Subject\SubjectStatus::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->subjectRepository->save($subject2); + + $handler = $this->createHandler(); + + $assignment1 = $handler($this->createCommand()); + $assignment2 = $handler($this->createCommand(subjectId: $secondSubjectId)); + + self::assertFalse($assignment1->id->equals($assignment2->id)); + } + + #[Test] + public function itAllowsDifferentTeacherSameClassSubject(): void + { + $secondTeacherId = '550e8400-e29b-41d4-a716-446655440011'; + $teacher2 = User::creer( + email: new Email('teacher2@example.com'), + role: Role::PROF, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Test', + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15'), + ); + // Reconstitute with known ID + $teacher2 = User::reconstitute( + id: \App\Administration\Domain\Model\User\UserId::fromString($secondTeacherId), + email: new Email('teacher2@example.com'), + roles: [Role::PROF], + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Test', + statut: \App\Administration\Domain\Model\User\StatutCompte::EN_ATTENTE, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15'), + hashedPassword: null, + activatedAt: null, + consentementParental: null, + ); + $this->userRepository->save($teacher2); + + $handler = $this->createHandler(); + + $assignment1 = $handler($this->createCommand()); + $assignment2 = $handler($this->createCommand(teacherId: $secondTeacherId)); + + self::assertFalse($assignment1->id->equals($assignment2->id)); + } + + #[Test] + public function itThrowsWhenTeacherDoesNotExist(): void + { + $handler = $this->createHandler(); + + $this->expectException(UserNotFoundException::class); + $handler($this->createCommand(teacherId: '550e8400-e29b-41d4-a716-446655440099')); + } + + #[Test] + public function itThrowsWhenClassDoesNotExist(): void + { + $handler = $this->createHandler(); + + $this->expectException(ClasseNotFoundException::class); + $handler($this->createCommand(classId: '550e8400-e29b-41d4-a716-446655440099')); + } + + #[Test] + public function itThrowsWhenSubjectDoesNotExist(): void + { + $handler = $this->createHandler(); + + $this->expectException(SubjectNotFoundException::class); + $handler($this->createCommand(subjectId: '550e8400-e29b-41d4-a716-446655440099')); + } + + private function seedTestData(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + + // Create teacher + $teacher = User::reconstitute( + id: \App\Administration\Domain\Model\User\UserId::fromString(self::TEACHER_ID), + email: new Email('teacher@example.com'), + roles: [Role::PROF], + tenantId: $tenantId, + schoolName: 'École Test', + statut: \App\Administration\Domain\Model\User\StatutCompte::EN_ATTENTE, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15'), + hashedPassword: null, + activatedAt: null, + consentementParental: null, + ); + $this->userRepository->save($teacher); + + // Create class + $class = SchoolClass::reconstitute( + id: \App\Administration\Domain\Model\SchoolClass\ClassId::fromString(self::CLASS_ID), + tenantId: $tenantId, + 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: \App\Administration\Domain\Model\SchoolClass\ClassStatus::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->classRepository->save($class); + + // Create subject + $subject = Subject::reconstitute( + id: \App\Administration\Domain\Model\Subject\SubjectId::fromString(self::SUBJECT_ID), + tenantId: $tenantId, + schoolId: SchoolId::fromString(self::SCHOOL_ID), + name: new SubjectName('Mathématiques'), + code: new SubjectCode('MATH'), + color: null, + status: \App\Administration\Domain\Model\Subject\SubjectStatus::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + ); + $this->subjectRepository->save($subject); + } + + private function createHandler(): AssignTeacherHandler + { + return new AssignTeacherHandler( + $this->assignmentRepository, + $this->userRepository, + $this->classRepository, + $this->subjectRepository, + $this->clock, + ); + } + + private function createCommand( + ?string $teacherId = null, + ?string $classId = null, + ?string $subjectId = null, + ): AssignTeacherCommand { + return new AssignTeacherCommand( + tenantId: self::TENANT_ID, + teacherId: $teacherId ?? self::TEACHER_ID, + classId: $classId ?? self::CLASS_ID, + subjectId: $subjectId ?? self::SUBJECT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/RemoveAssignment/RemoveAssignmentHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/RemoveAssignment/RemoveAssignmentHandlerTest.php new file mode 100644 index 0000000..72caab5 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/RemoveAssignment/RemoveAssignmentHandlerTest.php @@ -0,0 +1,121 @@ +assignmentRepository = new InMemoryTeacherAssignmentRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-01 10:00:00'); + } + }; + } + + #[Test] + public function itRemovesAssignmentSuccessfully(): void + { + $assignment = $this->createAndSaveAssignment(); + $handler = new RemoveAssignmentHandler($this->assignmentRepository, $this->clock); + + $command = new RemoveAssignmentCommand( + assignmentId: (string) $assignment->id, + tenantId: self::TENANT_ID, + ); + + $result = $handler($command); + + self::assertSame(AssignmentStatus::REMOVED, $result->status); + self::assertFalse($result->estActive()); + self::assertNotNull($result->endDate); + } + + #[Test] + public function itThrowsExceptionWhenAssignmentNotFound(): void + { + $handler = new RemoveAssignmentHandler($this->assignmentRepository, $this->clock); + + $command = new RemoveAssignmentCommand( + assignmentId: '550e8400-e29b-41d4-a716-446655440099', + tenantId: self::TENANT_ID, + ); + + $this->expectException(AffectationNotFoundException::class); + $handler($command); + } + + #[Test] + public function itThrowsExceptionWhenTenantMismatch(): void + { + $assignment = $this->createAndSaveAssignment(); + $handler = new RemoveAssignmentHandler($this->assignmentRepository, $this->clock); + + $command = new RemoveAssignmentCommand( + assignmentId: (string) $assignment->id, + tenantId: '550e8400-e29b-41d4-a716-446655440099', + ); + + $this->expectException(AffectationNotFoundException::class); + $handler($command); + } + + #[Test] + public function itIsIdempotentWhenAlreadyRemoved(): void + { + $assignment = $this->createAndSaveAssignment(); + $handler = new RemoveAssignmentHandler($this->assignmentRepository, $this->clock); + + $command = new RemoveAssignmentCommand( + assignmentId: (string) $assignment->id, + tenantId: self::TENANT_ID, + ); + + $result1 = $handler($command); + $result2 = $handler($command); + + self::assertSame(AssignmentStatus::REMOVED, $result2->status); + self::assertEquals($result1->endDate, $result2->endDate); + } + + private function createAndSaveAssignment(): TeacherAssignment + { + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440040'), + createdAt: new DateTimeImmutable('2026-02-12 10:00:00'), + ); + + $this->assignmentRepository->save($assignment); + + return $assignment; + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetAssignment/GetAssignmentHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetAssignment/GetAssignmentHandlerTest.php new file mode 100644 index 0000000..064d664 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetAssignment/GetAssignmentHandlerTest.php @@ -0,0 +1,126 @@ +repository = new InMemoryTeacherAssignmentRepository(); + $this->handler = new GetAssignmentHandler($this->repository); + } + + #[Test] + public function returnsNullWhenNoAssignmentFound(): void + { + $result = ($this->handler)(new GetAssignmentQuery( + teacherId: self::TEACHER_ID, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + tenantId: self::TENANT_ID, + )); + + self::assertNull($result); + } + + #[Test] + public function returnsAssignmentDto(): void + { + $assignment = $this->createAndSaveAssignment(); + + $result = ($this->handler)(new GetAssignmentQuery( + teacherId: self::TEACHER_ID, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + tenantId: self::TENANT_ID, + )); + + self::assertInstanceOf(TeacherAssignmentDto::class, $result); + self::assertSame((string) $assignment->id, $result->id); + self::assertSame(self::TEACHER_ID, $result->teacherId); + self::assertSame(self::CLASS_ID, $result->classId); + self::assertSame(self::SUBJECT_ID, $result->subjectId); + } + + #[Test] + public function returnsNullForRemovedAssignment(): void + { + $assignment = $this->createAndSaveAssignment(); + $assignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00')); + $this->repository->save($assignment); + + $result = ($this->handler)(new GetAssignmentQuery( + teacherId: self::TEACHER_ID, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + tenantId: self::TENANT_ID, + )); + + self::assertNull($result); + } + + #[Test] + public function dtoContainsCorrectData(): void + { + $createdAt = new DateTimeImmutable('2026-02-10 10:00:00'); + $this->createAndSaveAssignment(); + + $result = ($this->handler)(new GetAssignmentQuery( + teacherId: self::TEACHER_ID, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + tenantId: self::TENANT_ID, + )); + + self::assertSame(self::ACADEMIC_YEAR_ID, $result->academicYearId); + self::assertSame('active', $result->status); + self::assertEquals($createdAt, $result->startDate); + self::assertNull($result->endDate); + self::assertEquals($createdAt, $result->createdAt); + } + + private function createAndSaveAssignment(): TeacherAssignment + { + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + + $this->repository->save($assignment); + + return $assignment; + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetAssignmentsForTeacher/GetAssignmentsForTeacherHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetAssignmentsForTeacher/GetAssignmentsForTeacherHandlerTest.php new file mode 100644 index 0000000..8016c1b --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetAssignmentsForTeacher/GetAssignmentsForTeacherHandlerTest.php @@ -0,0 +1,133 @@ +repository = new InMemoryTeacherAssignmentRepository(); + $this->handler = new GetAssignmentsForTeacherHandler($this->repository); + } + + #[Test] + public function returnsEmptyWhenNoAssignments(): void + { + $result = ($this->handler)(new GetAssignmentsForTeacherQuery( + teacherId: self::TEACHER_ID, + tenantId: self::TENANT_ID, + )); + + self::assertSame([], $result); + } + + #[Test] + public function returnsActiveAssignmentsForTeacher(): void + { + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440030'); + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440031'); + + $result = ($this->handler)(new GetAssignmentsForTeacherQuery( + teacherId: self::TEACHER_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(2, $result); + self::assertContainsOnlyInstancesOf(TeacherAssignmentDto::class, $result); + } + + #[Test] + public function excludesRemovedAssignments(): void + { + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440030'); + + $removed = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440031'), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + $removed->retirer(new DateTimeImmutable('2026-02-11 10:00:00')); + $this->repository->save($removed); + + $result = ($this->handler)(new GetAssignmentsForTeacherQuery( + teacherId: self::TEACHER_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + } + + #[Test] + public function excludesAssignmentsFromDifferentTenant(): void + { + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440030'); + + $result = ($this->handler)(new GetAssignmentsForTeacherQuery( + teacherId: self::TEACHER_ID, + tenantId: '550e8400-e29b-41d4-a716-446655440099', + )); + + self::assertSame([], $result); + } + + #[Test] + public function dtoContainsCorrectData(): void + { + $createdAt = new DateTimeImmutable('2026-02-10 10:00:00'); + $subjectId = '550e8400-e29b-41d4-a716-446655440030'; + $this->createAndSaveAssignment($subjectId); + + $result = ($this->handler)(new GetAssignmentsForTeacherQuery( + teacherId: self::TEACHER_ID, + tenantId: self::TENANT_ID, + )); + + self::assertSame(self::TEACHER_ID, $result[0]->teacherId); + self::assertSame(self::CLASS_ID, $result[0]->classId); + self::assertSame($subjectId, $result[0]->subjectId); + self::assertSame('active', $result[0]->status); + self::assertEquals($createdAt, $result[0]->startDate); + self::assertNull($result[0]->endDate); + } + + private function createAndSaveAssignment(string $subjectId): void + { + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString($subjectId), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + + $this->repository->save($assignment); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassHandlerTest.php new file mode 100644 index 0000000..233d02f --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassHandlerTest.php @@ -0,0 +1,131 @@ +repository = new InMemoryTeacherAssignmentRepository(); + $this->handler = new GetTeachersForClassHandler($this->repository); + } + + #[Test] + public function returnsEmptyWhenNoTeachersAssigned(): void + { + $result = ($this->handler)(new GetTeachersForClassQuery( + classId: self::CLASS_ID, + tenantId: self::TENANT_ID, + )); + + self::assertSame([], $result); + } + + #[Test] + public function returnsTeachersForClass(): void + { + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440010'); + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440011'); + + $result = ($this->handler)(new GetTeachersForClassQuery( + classId: self::CLASS_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(2, $result); + self::assertContainsOnlyInstancesOf(TeacherAssignmentDto::class, $result); + } + + #[Test] + public function excludesRemovedAssignments(): void + { + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440010'); + + $removed = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440011'), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + $removed->retirer(new DateTimeImmutable('2026-02-11 10:00:00')); + $this->repository->save($removed); + + $result = ($this->handler)(new GetTeachersForClassQuery( + classId: self::CLASS_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + } + + #[Test] + public function excludesAssignmentsFromDifferentTenant(): void + { + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440010'); + + $result = ($this->handler)(new GetTeachersForClassQuery( + classId: self::CLASS_ID, + tenantId: '550e8400-e29b-41d4-a716-446655440099', + )); + + self::assertSame([], $result); + } + + #[Test] + public function dtoContainsCorrectData(): void + { + $teacherId = '550e8400-e29b-41d4-a716-446655440010'; + $createdAt = new DateTimeImmutable('2026-02-10 10:00:00'); + $this->createAndSaveAssignment($teacherId); + + $result = ($this->handler)(new GetTeachersForClassQuery( + classId: self::CLASS_ID, + tenantId: self::TENANT_ID, + )); + + self::assertSame($teacherId, $result[0]->teacherId); + self::assertSame(self::CLASS_ID, $result[0]->classId); + self::assertSame('active', $result[0]->status); + self::assertEquals($createdAt, $result[0]->startDate); + self::assertNull($result[0]->endDate); + } + + private function createAndSaveAssignment(string $teacherId): void + { + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString($teacherId), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + + $this->repository->save($assignment); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/TeacherAssignment/TeacherAssignmentTest.php b/backend/tests/Unit/Administration/Domain/Model/TeacherAssignment/TeacherAssignmentTest.php new file mode 100644 index 0000000..93d40f6 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/TeacherAssignment/TeacherAssignmentTest.php @@ -0,0 +1,175 @@ +createAssignment(); + + self::assertSame(AssignmentStatus::ACTIVE, $assignment->status); + self::assertTrue($assignment->estActive()); + self::assertNull($assignment->endDate); + } + + #[Test] + public function creerRecordsEnseignantAffecteEvent(): void + { + $assignment = $this->createAssignment(); + + $events = $assignment->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(EnseignantAffecte::class, $events[0]); + self::assertSame($assignment->id, $events[0]->assignmentId); + self::assertSame($assignment->teacherId, $events[0]->teacherId); + self::assertSame($assignment->classId, $events[0]->classId); + self::assertSame($assignment->subjectId, $events[0]->subjectId); + } + + #[Test] + public function creerSetsAllProperties(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $teacherId = UserId::fromString(self::TEACHER_ID); + $classId = ClassId::fromString(self::CLASS_ID); + $subjectId = SubjectId::fromString(self::SUBJECT_ID); + $academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID); + $createdAt = new DateTimeImmutable('2026-02-12 10:00:00'); + + $assignment = TeacherAssignment::creer( + tenantId: $tenantId, + teacherId: $teacherId, + classId: $classId, + subjectId: $subjectId, + academicYearId: $academicYearId, + createdAt: $createdAt, + ); + + self::assertTrue($assignment->tenantId->equals($tenantId)); + self::assertTrue($assignment->teacherId->equals($teacherId)); + self::assertTrue($assignment->classId->equals($classId)); + self::assertTrue($assignment->subjectId->equals($subjectId)); + self::assertTrue($assignment->academicYearId->equals($academicYearId)); + self::assertEquals($createdAt, $assignment->startDate); + self::assertEquals($createdAt, $assignment->createdAt); + self::assertEquals($createdAt, $assignment->updatedAt); + self::assertNull($assignment->endDate); + } + + #[Test] + public function retirerChangesStatusAndRecordsEvent(): void + { + $assignment = $this->createAssignment(); + $assignment->pullDomainEvents(); + $at = new DateTimeImmutable('2026-03-01 10:00:00'); + + $assignment->retirer($at); + + self::assertSame(AssignmentStatus::REMOVED, $assignment->status); + self::assertFalse($assignment->estActive()); + self::assertEquals($at, $assignment->endDate); + self::assertEquals($at, $assignment->updatedAt); + + $events = $assignment->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(AffectationRetiree::class, $events[0]); + self::assertSame($assignment->id, $events[0]->assignmentId); + self::assertSame($assignment->teacherId, $events[0]->teacherId); + self::assertSame($assignment->classId, $events[0]->classId); + self::assertSame($assignment->subjectId, $events[0]->subjectId); + } + + #[Test] + public function retirerAlreadyRemovedAssignmentDoesNothing(): void + { + $assignment = $this->createAssignment(); + $assignment->retirer(new DateTimeImmutable('2026-03-01 10:00:00')); + $assignment->pullDomainEvents(); + $originalEndDate = $assignment->endDate; + + $assignment->retirer(new DateTimeImmutable('2026-03-02 10:00:00')); + + self::assertEquals($originalEndDate, $assignment->endDate); + self::assertEmpty($assignment->pullDomainEvents()); + } + + #[Test] + public function reconstituteRestoresAllProperties(): void + { + $id = TeacherAssignmentId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $teacherId = UserId::fromString(self::TEACHER_ID); + $classId = ClassId::fromString(self::CLASS_ID); + $subjectId = SubjectId::fromString(self::SUBJECT_ID); + $academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID); + $startDate = new DateTimeImmutable('2026-02-12 10:00:00'); + $endDate = new DateTimeImmutable('2026-03-01 10:00:00'); + $status = AssignmentStatus::REMOVED; + $createdAt = new DateTimeImmutable('2026-02-12 10:00:00'); + $updatedAt = new DateTimeImmutable('2026-03-01 10:00:00'); + + $assignment = TeacherAssignment::reconstitute( + id: $id, + tenantId: $tenantId, + teacherId: $teacherId, + classId: $classId, + subjectId: $subjectId, + academicYearId: $academicYearId, + startDate: $startDate, + endDate: $endDate, + status: $status, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + self::assertTrue($assignment->id->equals($id)); + self::assertTrue($assignment->tenantId->equals($tenantId)); + self::assertTrue($assignment->teacherId->equals($teacherId)); + self::assertTrue($assignment->classId->equals($classId)); + self::assertTrue($assignment->subjectId->equals($subjectId)); + self::assertTrue($assignment->academicYearId->equals($academicYearId)); + self::assertEquals($startDate, $assignment->startDate); + self::assertEquals($endDate, $assignment->endDate); + self::assertSame($status, $assignment->status); + self::assertEquals($createdAt, $assignment->createdAt); + self::assertEquals($updatedAt, $assignment->updatedAt); + self::assertEmpty($assignment->pullDomainEvents()); + } + + private function createAssignment(): TeacherAssignment + { + return TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-12 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/CreateClassProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/CreateClassProcessorTest.php index 5d1341a..d7392af 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/CreateClassProcessorTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/CreateClassProcessorTest.php @@ -11,6 +11,7 @@ use App\Administration\Infrastructure\Api\Processor\CreateClassProcessor; use App\Administration\Infrastructure\Api\Resource\ClassResource; use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository; use App\Administration\Infrastructure\Security\ClassVoter; +use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver; use App\Shared\Domain\Clock; use App\Shared\Infrastructure\Tenant\TenantConfig; use App\Shared\Infrastructure\Tenant\TenantContext; @@ -154,11 +155,19 @@ final class CreateClassProcessorTest extends TestCase ->with(ClassVoter::CREATE) ->willReturn($authorized); + $resolvedTenantContext = $tenantContext ?? $this->tenantContext; + + $academicYearResolver = new CurrentAcademicYearResolver( + $resolvedTenantContext, + $this->clock, + ); + return new CreateClassProcessor( $handler, - $tenantContext ?? $this->tenantContext, + $resolvedTenantContext, $eventBus, $authorizationChecker, + $academicYearResolver, ); } } diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/CreateTeacherAssignmentProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/CreateTeacherAssignmentProcessorTest.php new file mode 100644 index 0000000..b0b3197 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/CreateTeacherAssignmentProcessorTest.php @@ -0,0 +1,294 @@ +repository = new InMemoryTeacherAssignmentRepository(); + $this->userRepository = new InMemoryUserRepository(); + $this->classRepository = new InMemoryClassRepository(); + $this->subjectRepository = new InMemorySubjectRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-10 10:00:00'); + } + }; + + $this->tenantContext = new TenantContext(); + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_ID), + subdomain: 'ecole-alpha', + databaseUrl: 'postgresql://test', + )); + + $this->seedTestData(); + } + + private function seedTestData(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + + $this->userRepository->save(User::reconstitute( + id: UserId::fromString(self::TEACHER_ID), + email: new Email('teacher@example.com'), + roles: [Role::PROF], + tenantId: $tenantId, + schoolName: 'École Test', + statut: StatutCompte::EN_ATTENTE, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15'), + hashedPassword: null, + activatedAt: null, + consentementParental: null, + )); + + $this->classRepository->save(SchoolClass::reconstitute( + id: ClassId::fromString(self::CLASS_ID), + tenantId: $tenantId, + 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->subjectRepository->save(Subject::reconstitute( + id: SubjectId::fromString(self::SUBJECT_ID), + tenantId: $tenantId, + schoolId: SchoolId::fromString(self::SCHOOL_ID), + name: new SubjectName('Mathématiques'), + code: new SubjectCode('MATH'), + color: null, + status: SubjectStatus::ACTIVE, + description: null, + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + deletedAt: null, + )); + } + + #[Test] + public function createsAssignmentSuccessfully(): void + { + $processor = $this->createProcessor(); + + $data = new TeacherAssignmentResource(); + $data->teacherId = self::TEACHER_ID; + $data->classId = self::CLASS_ID; + $data->subjectId = self::SUBJECT_ID; + $data->academicYearId = self::ACADEMIC_YEAR_ID; + + $result = $processor->process($data, new Post()); + + self::assertNotNull($result->id); + self::assertSame(self::TEACHER_ID, $result->teacherId); + self::assertSame(self::CLASS_ID, $result->classId); + self::assertSame(self::SUBJECT_ID, $result->subjectId); + self::assertSame('active', $result->status); + } + + #[Test] + public function resolvesCurrentAcademicYear(): void + { + $processor = $this->createProcessor(); + + $data = new TeacherAssignmentResource(); + $data->teacherId = self::TEACHER_ID; + $data->classId = self::CLASS_ID; + $data->subjectId = self::SUBJECT_ID; + $data->academicYearId = 'current'; + + $result = $processor->process($data, new Post()); + + self::assertNotNull($result->id); + self::assertNotNull($result->academicYearId); + } + + #[Test] + public function throwsWhenNotAuthorized(): void + { + $processor = $this->createProcessor(authorized: false); + + $data = new TeacherAssignmentResource(); + $data->teacherId = self::TEACHER_ID; + $data->classId = self::CLASS_ID; + $data->subjectId = self::SUBJECT_ID; + $data->academicYearId = self::ACADEMIC_YEAR_ID; + + $this->expectException(AccessDeniedHttpException::class); + + $processor->process($data, new Post()); + } + + #[Test] + public function throwsWhenTenantNotSet(): void + { + $emptyTenantContext = new TenantContext(); + $processor = $this->createProcessor(tenantContext: $emptyTenantContext); + + $data = new TeacherAssignmentResource(); + $data->teacherId = self::TEACHER_ID; + $data->classId = self::CLASS_ID; + $data->subjectId = self::SUBJECT_ID; + $data->academicYearId = self::ACADEMIC_YEAR_ID; + + $this->expectException(UnauthorizedHttpException::class); + + $processor->process($data, new Post()); + } + + #[Test] + public function throwsConflictWhenDuplicateAssignment(): void + { + $processor = $this->createProcessor(); + + $data = new TeacherAssignmentResource(); + $data->teacherId = self::TEACHER_ID; + $data->classId = self::CLASS_ID; + $data->subjectId = self::SUBJECT_ID; + $data->academicYearId = self::ACADEMIC_YEAR_ID; + + $processor->process($data, new Post()); + + $this->expectException(ConflictHttpException::class); + + $processor->process($data, new Post()); + } + + #[Test] + public function throwsBadRequestForInvalidUuid(): void + { + $processor = $this->createProcessor(); + + $data = new TeacherAssignmentResource(); + $data->teacherId = 'not-a-uuid'; + $data->classId = self::CLASS_ID; + $data->subjectId = self::SUBJECT_ID; + $data->academicYearId = self::ACADEMIC_YEAR_ID; + + $this->expectException(BadRequestHttpException::class); + + $processor->process($data, new Post()); + } + + #[Test] + public function throwsBadRequestForInvalidAcademicYearId(): void + { + $processor = $this->createProcessor(); + + $data = new TeacherAssignmentResource(); + $data->teacherId = self::TEACHER_ID; + $data->classId = self::CLASS_ID; + $data->subjectId = self::SUBJECT_ID; + $data->academicYearId = 'invalid-keyword'; + + $this->expectException(BadRequestHttpException::class); + + $processor->process($data, new Post()); + } + + private function createProcessor( + bool $authorized = true, + ?TenantContext $tenantContext = null, + ): CreateTeacherAssignmentProcessor { + $handler = new AssignTeacherHandler( + $this->repository, + $this->userRepository, + $this->classRepository, + $this->subjectRepository, + $this->clock, + ); + + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch')->willReturnCallback( + static fn (object $message) => new Envelope($message), + ); + + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->method('isGranted') + ->with(TeacherAssignmentVoter::CREATE) + ->willReturn($authorized); + + $resolvedTenantContext = $tenantContext ?? $this->tenantContext; + + $academicYearResolver = new CurrentAcademicYearResolver( + $resolvedTenantContext, + $this->clock, + ); + + return new CreateTeacherAssignmentProcessor( + $handler, + $resolvedTenantContext, + $eventBus, + $authorizationChecker, + $academicYearResolver, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/RemoveTeacherAssignmentProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/RemoveTeacherAssignmentProcessorTest.php new file mode 100644 index 0000000..77fbaf9 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/RemoveTeacherAssignmentProcessorTest.php @@ -0,0 +1,192 @@ +repository = new InMemoryTeacherAssignmentRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-10 10:00:00'); + } + }; + + $this->tenantContext = new TenantContext(); + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_ID), + subdomain: 'ecole-alpha', + databaseUrl: 'postgresql://test', + )); + } + + #[Test] + public function removesAssignmentSuccessfully(): void + { + $assignment = $this->createAndSaveAssignment(); + $processor = $this->createProcessor(); + + $result = $processor->process( + new TeacherAssignmentResource(), + new Delete(), + uriVariables: ['id' => (string) $assignment->id], + ); + + self::assertNull($result); + } + + #[Test] + public function throwsWhenNotAuthorized(): void + { + $assignment = $this->createAndSaveAssignment(); + $processor = $this->createProcessor(authorized: false); + + $this->expectException(AccessDeniedHttpException::class); + + $processor->process( + new TeacherAssignmentResource(), + new Delete(), + uriVariables: ['id' => (string) $assignment->id], + ); + } + + #[Test] + public function throwsWhenTenantNotSet(): void + { + $assignment = $this->createAndSaveAssignment(); + $emptyTenantContext = new TenantContext(); + $processor = $this->createProcessor(tenantContext: $emptyTenantContext); + + $this->expectException(UnauthorizedHttpException::class); + + $processor->process( + new TeacherAssignmentResource(), + new Delete(), + uriVariables: ['id' => (string) $assignment->id], + ); + } + + #[Test] + public function throwsNotFoundWhenAssignmentDoesNotExist(): void + { + $processor = $this->createProcessor(); + + $this->expectException(NotFoundHttpException::class); + + $processor->process( + new TeacherAssignmentResource(), + new Delete(), + uriVariables: ['id' => '550e8400-e29b-41d4-a716-446655440099'], + ); + } + + #[Test] + public function throwsNotFoundWhenNoIdProvided(): void + { + $processor = $this->createProcessor(); + + $this->expectException(NotFoundHttpException::class); + + $processor->process( + new TeacherAssignmentResource(), + new Delete(), + ); + } + + #[Test] + public function throwsNotFoundForInvalidUuid(): void + { + $processor = $this->createProcessor(); + + $this->expectException(NotFoundHttpException::class); + + $processor->process( + new TeacherAssignmentResource(), + new Delete(), + uriVariables: ['id' => 'not-a-uuid'], + ); + } + + private function createAndSaveAssignment(): TeacherAssignment + { + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + + // Consommer les événements du domaine pour éviter les interférences + $assignment->pullDomainEvents(); + + $this->repository->save($assignment); + + return $assignment; + } + + private function createProcessor( + bool $authorized = true, + ?TenantContext $tenantContext = null, + ): RemoveTeacherAssignmentProcessor { + $handler = new RemoveAssignmentHandler($this->repository, $this->clock); + + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch')->willReturnCallback( + static fn (object $message) => new Envelope($message), + ); + + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->method('isGranted') + ->with(TeacherAssignmentVoter::DELETE) + ->willReturn($authorized); + + return new RemoveTeacherAssignmentProcessor( + $handler, + $tenantContext ?? $this->tenantContext, + $eventBus, + $authorizationChecker, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentItemProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentItemProviderTest.php new file mode 100644 index 0000000..ab9f81a --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentItemProviderTest.php @@ -0,0 +1,151 @@ +repository = new InMemoryTeacherAssignmentRepository(); + + $this->tenantContext = new TenantContext(); + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_ID), + subdomain: 'ecole-alpha', + databaseUrl: 'postgresql://test', + )); + } + + #[Test] + public function returnsAssignmentById(): void + { + $assignment = $this->createAndSaveAssignment(); + $provider = $this->createProvider(); + + $result = $provider->provide( + new Get(), + ['id' => (string) $assignment->id], + ); + + self::assertInstanceOf(TeacherAssignmentResource::class, $result); + self::assertSame((string) $assignment->id, $result->id); + self::assertSame(self::TEACHER_ID, $result->teacherId); + } + + #[Test] + public function returnsNullForUnknownId(): void + { + $provider = $this->createProvider(); + + $result = $provider->provide( + new Get(), + ['id' => '550e8400-e29b-41d4-a716-446655440099'], + ); + + self::assertNull($result); + } + + #[Test] + public function returnsNullForInvalidUuid(): void + { + $provider = $this->createProvider(); + + $result = $provider->provide( + new Get(), + ['id' => 'not-a-uuid'], + ); + + self::assertNull($result); + } + + #[Test] + public function throwsUnauthorizedWhenNoTenant(): void + { + $tenantContext = new TenantContext(); + $provider = $this->createProvider(tenantContext: $tenantContext); + + $this->expectException(UnauthorizedHttpException::class); + + $provider->provide( + new Get(), + ['id' => '550e8400-e29b-41d4-a716-446655440099'], + ); + } + + #[Test] + public function throwsAccessDeniedWhenNotAuthorized(): void + { + $provider = $this->createProvider(authorized: false); + + $this->expectException(AccessDeniedHttpException::class); + + $provider->provide( + new Get(), + ['id' => '550e8400-e29b-41d4-a716-446655440099'], + ); + } + + private function createAndSaveAssignment(): TeacherAssignment + { + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + + $this->repository->save($assignment); + + return $assignment; + } + + private function createProvider( + bool $authorized = true, + ?TenantContext $tenantContext = null, + ): TeacherAssignmentItemProvider { + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->method('isGranted') + ->with(TeacherAssignmentVoter::DELETE) + ->willReturn($authorized); + + return new TeacherAssignmentItemProvider( + $this->repository, + $tenantContext ?? $this->tenantContext, + $authorizationChecker, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByClassProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByClassProviderTest.php new file mode 100644 index 0000000..c4f3a82 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByClassProviderTest.php @@ -0,0 +1,151 @@ +repository = new InMemoryTeacherAssignmentRepository(); + + $this->tenantContext = new TenantContext(); + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_ID), + subdomain: 'ecole-alpha', + databaseUrl: 'postgresql://test', + )); + } + + #[Test] + public function returnsTeachersForClass(): void + { + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440010'); + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440011'); + + $provider = $this->createProvider(); + + $results = $provider->provide( + new GetCollection(), + ['classId' => self::CLASS_ID], + ); + + self::assertCount(2, $results); + self::assertContainsOnlyInstancesOf(TeacherAssignmentResource::class, $results); + } + + #[Test] + public function returnsEmptyArrayWhenNoTeachersAssigned(): void + { + $provider = $this->createProvider(); + + $results = $provider->provide( + new GetCollection(), + ['classId' => self::CLASS_ID], + ); + + self::assertSame([], $results); + } + + #[Test] + public function throwsBadRequestForInvalidUuid(): void + { + $provider = $this->createProvider(); + + $this->expectException(\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class); + + $provider->provide( + new GetCollection(), + ['classId' => 'not-a-uuid'], + ); + } + + #[Test] + public function throwsAccessDeniedWhenNotAuthorized(): void + { + $provider = $this->createProvider(authorized: false); + + $this->expectException(AccessDeniedHttpException::class); + + $provider->provide( + new GetCollection(), + ['classId' => self::CLASS_ID], + ); + } + + #[Test] + public function throwsUnauthorizedWhenNoTenant(): void + { + $tenantContext = new TenantContext(); + $provider = $this->createProvider(tenantContext: $tenantContext); + + $this->expectException(UnauthorizedHttpException::class); + + $provider->provide( + new GetCollection(), + ['classId' => self::CLASS_ID], + ); + } + + private function createAndSaveAssignment(string $teacherId): void + { + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString($teacherId), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + + $this->repository->save($assignment); + } + + private function createProvider( + bool $authorized = true, + ?TenantContext $tenantContext = null, + ): TeacherAssignmentsByClassProvider { + $handler = new GetTeachersForClassHandler($this->repository); + + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->method('isGranted') + ->with(TeacherAssignmentVoter::VIEW) + ->willReturn($authorized); + + return new TeacherAssignmentsByClassProvider( + $handler, + $tenantContext ?? $this->tenantContext, + $authorizationChecker, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByTeacherProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByTeacherProviderTest.php new file mode 100644 index 0000000..449a411 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByTeacherProviderTest.php @@ -0,0 +1,178 @@ +repository = new InMemoryTeacherAssignmentRepository(); + + $this->tenantContext = new TenantContext(); + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_ID), + subdomain: 'ecole-alpha', + databaseUrl: 'postgresql://test', + )); + } + + #[Test] + public function returnsAssignmentsForTeacher(): void + { + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440020'); + $this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440021'); + + $provider = $this->createProvider(); + + $results = $provider->provide( + new GetCollection(), + ['teacherId' => self::TEACHER_ID], + ); + + self::assertCount(2, $results); + self::assertContainsOnlyInstancesOf(TeacherAssignmentResource::class, $results); + } + + #[Test] + public function returnsEmptyArrayWhenNoAssignments(): void + { + $provider = $this->createProvider(); + + $results = $provider->provide( + new GetCollection(), + ['teacherId' => self::TEACHER_ID], + ); + + self::assertSame([], $results); + } + + #[Test] + public function throwsBadRequestForInvalidUuid(): void + { + $provider = $this->createProvider(); + + $this->expectException(\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class); + + $provider->provide( + new GetCollection(), + ['teacherId' => 'not-a-uuid'], + ); + } + + #[Test] + public function throwsAccessDeniedWhenNotAuthorized(): void + { + $provider = $this->createProvider(authorized: false); + + $this->expectException(AccessDeniedHttpException::class); + + $provider->provide( + new GetCollection(), + ['teacherId' => self::TEACHER_ID], + ); + } + + #[Test] + public function throwsUnauthorizedWhenNoTenant(): void + { + $tenantContext = new TenantContext(); + $provider = $this->createProvider(tenantContext: $tenantContext); + + $this->expectException(UnauthorizedHttpException::class); + + $provider->provide( + new GetCollection(), + ['teacherId' => self::TEACHER_ID], + ); + } + + #[Test] + public function passesTeacherIdAsSubjectToVoter(): void + { + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->expects(self::once()) + ->method('isGranted') + ->with( + TeacherAssignmentVoter::VIEW, + self::callback(static function (TeacherAssignmentResource $subject): bool { + return $subject->teacherId === self::TEACHER_ID; + }), + ) + ->willReturn(true); + + $handler = new GetAssignmentsForTeacherHandler($this->repository); + + $provider = new TeacherAssignmentsByTeacherProvider( + $handler, + $this->tenantContext, + $authorizationChecker, + ); + + $provider->provide( + new GetCollection(), + ['teacherId' => self::TEACHER_ID], + ); + } + + private function createAndSaveAssignment(string $classId): void + { + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + + $this->repository->save($assignment); + } + + private function createProvider( + bool $authorized = true, + ?TenantContext $tenantContext = null, + ): TeacherAssignmentsByTeacherProvider { + $handler = new GetAssignmentsForTeacherHandler($this->repository); + + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->method('isGranted') + ->willReturn($authorized); + + return new TeacherAssignmentsByTeacherProvider( + $handler, + $tenantContext ?? $this->tenantContext, + $authorizationChecker, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Resource/TeacherAssignmentResourceTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Resource/TeacherAssignmentResourceTest.php new file mode 100644 index 0000000..f86e80c --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Resource/TeacherAssignmentResourceTest.php @@ -0,0 +1,105 @@ +id, $resource->id); + self::assertSame(self::TEACHER_ID, $resource->teacherId); + self::assertSame(self::CLASS_ID, $resource->classId); + self::assertSame(self::SUBJECT_ID, $resource->subjectId); + self::assertSame(self::ACADEMIC_YEAR_ID, $resource->academicYearId); + self::assertSame('active', $resource->status); + self::assertEquals($createdAt, $resource->startDate); + self::assertNull($resource->endDate); + self::assertEquals($createdAt, $resource->createdAt); + } + + #[Test] + public function fromDomainMapsRemovedAssignment(): void + { + $createdAt = new DateTimeImmutable('2026-02-10 10:00:00'); + $removedAt = new DateTimeImmutable('2026-02-11 14:00:00'); + + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: $createdAt, + ); + $assignment->retirer($removedAt); + + $resource = TeacherAssignmentResource::fromDomain($assignment); + + self::assertSame('removed', $resource->status); + self::assertEquals($removedAt, $resource->endDate); + } + + #[Test] + public function fromDtoMapsCorrectly(): void + { + $createdAt = new DateTimeImmutable('2026-02-10 10:00:00'); + + $dto = new TeacherAssignmentDto( + id: '550e8400-e29b-41d4-a716-446655440099', + teacherId: self::TEACHER_ID, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + status: 'active', + startDate: $createdAt, + endDate: null, + createdAt: $createdAt, + ); + + $resource = TeacherAssignmentResource::fromDto($dto); + + self::assertSame('550e8400-e29b-41d4-a716-446655440099', $resource->id); + self::assertSame(self::TEACHER_ID, $resource->teacherId); + self::assertSame(self::CLASS_ID, $resource->classId); + self::assertSame(self::SUBJECT_ID, $resource->subjectId); + self::assertSame(self::ACADEMIC_YEAR_ID, $resource->academicYearId); + self::assertSame('active', $resource->status); + self::assertEquals($createdAt, $resource->startDate); + self::assertNull($resource->endDate); + self::assertEquals($createdAt, $resource->createdAt); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepositoryTest.php b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepositoryTest.php new file mode 100644 index 0000000..e5ae825 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepositoryTest.php @@ -0,0 +1,290 @@ +repository = new InMemoryTeacherAssignmentRepository(); + } + + #[Test] + public function saveAndGet(): void + { + $assignment = $this->createAssignment(); + + $this->repository->save($assignment); + + $tenantId = TenantId::fromString(self::TENANT_ID); + $retrieved = $this->repository->get($assignment->id, $tenantId); + self::assertTrue($assignment->id->equals($retrieved->id)); + } + + #[Test] + public function getThrowsForUnknownId(): void + { + $this->expectException(AffectationNotFoundException::class); + + $this->repository->get( + TeacherAssignmentId::generate(), + TenantId::fromString(self::TENANT_ID), + ); + } + + #[Test] + public function getThrowsForDifferentTenant(): void + { + $assignment = $this->createAssignment(); + $this->repository->save($assignment); + + $this->expectException(AffectationNotFoundException::class); + + $this->repository->get( + $assignment->id, + TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'), + ); + } + + #[Test] + public function findByIdReturnsNullForUnknownId(): void + { + $result = $this->repository->findById( + TeacherAssignmentId::generate(), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertNull($result); + } + + #[Test] + public function findByIdReturnsNullForDifferentTenant(): void + { + $assignment = $this->createAssignment(); + $this->repository->save($assignment); + + $found = $this->repository->findById( + $assignment->id, + TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'), + ); + + self::assertNull($found); + } + + #[Test] + public function findByIdReturnsAssignment(): void + { + $assignment = $this->createAssignment(); + $this->repository->save($assignment); + + $found = $this->repository->findById( + $assignment->id, + TenantId::fromString(self::TENANT_ID), + ); + + self::assertNotNull($found); + self::assertTrue($assignment->id->equals($found->id)); + } + + #[Test] + public function findByTeacherClassSubjectReturnsActiveAssignment(): void + { + $assignment = $this->createAssignment(); + $this->repository->save($assignment); + + $found = $this->repository->findByTeacherClassSubject( + UserId::fromString(self::TEACHER_ID), + ClassId::fromString(self::CLASS_ID), + SubjectId::fromString(self::SUBJECT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertNotNull($found); + self::assertTrue($assignment->id->equals($found->id)); + } + + #[Test] + public function findByTeacherClassSubjectReturnsNullForRemovedAssignment(): void + { + $assignment = $this->createAssignment(); + $assignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00')); + $this->repository->save($assignment); + + $found = $this->repository->findByTeacherClassSubject( + UserId::fromString(self::TEACHER_ID), + ClassId::fromString(self::CLASS_ID), + SubjectId::fromString(self::SUBJECT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertNull($found); + } + + #[Test] + public function findByTeacherClassSubjectReturnsNullForDifferentTenant(): void + { + $assignment = $this->createAssignment(); + $this->repository->save($assignment); + + $found = $this->repository->findByTeacherClassSubject( + UserId::fromString(self::TEACHER_ID), + ClassId::fromString(self::CLASS_ID), + SubjectId::fromString(self::SUBJECT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'), + ); + + self::assertNull($found); + } + + #[Test] + public function findRemovedByTeacherClassSubjectReturnsRemovedAssignment(): void + { + $assignment = $this->createAssignment(); + $assignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00')); + $this->repository->save($assignment); + + $found = $this->repository->findRemovedByTeacherClassSubject( + UserId::fromString(self::TEACHER_ID), + ClassId::fromString(self::CLASS_ID), + SubjectId::fromString(self::SUBJECT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertNotNull($found); + self::assertTrue($assignment->id->equals($found->id)); + } + + #[Test] + public function findRemovedByTeacherClassSubjectReturnsNullForActiveAssignment(): void + { + $assignment = $this->createAssignment(); + $this->repository->save($assignment); + + $found = $this->repository->findRemovedByTeacherClassSubject( + UserId::fromString(self::TEACHER_ID), + ClassId::fromString(self::CLASS_ID), + SubjectId::fromString(self::SUBJECT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertNull($found); + } + + #[Test] + public function findActiveByTeacher(): void + { + $assignment1 = $this->createAssignment(); + $assignment2 = $this->createAssignment( + subjectId: '550e8400-e29b-41d4-a716-446655440031', + ); + $removedAssignment = $this->createAssignment( + subjectId: '550e8400-e29b-41d4-a716-446655440032', + ); + $removedAssignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00')); + + $this->repository->save($assignment1); + $this->repository->save($assignment2); + $this->repository->save($removedAssignment); + + $result = $this->repository->findActiveByTeacher( + UserId::fromString(self::TEACHER_ID), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertCount(2, $result); + } + + #[Test] + public function findActiveByTeacherReturnsEmptyForDifferentTenant(): void + { + $assignment = $this->createAssignment(); + $this->repository->save($assignment); + + $result = $this->repository->findActiveByTeacher( + UserId::fromString(self::TEACHER_ID), + TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'), + ); + + self::assertEmpty($result); + } + + #[Test] + public function findActiveByClass(): void + { + $assignment1 = $this->createAssignment(); + $assignment2 = $this->createAssignment( + teacherId: '550e8400-e29b-41d4-a716-446655440011', + ); + $removedAssignment = $this->createAssignment( + teacherId: '550e8400-e29b-41d4-a716-446655440012', + ); + $removedAssignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00')); + + $this->repository->save($assignment1); + $this->repository->save($assignment2); + $this->repository->save($removedAssignment); + + $result = $this->repository->findActiveByClass( + ClassId::fromString(self::CLASS_ID), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertCount(2, $result); + } + + #[Test] + public function findActiveByClassReturnsEmptyForDifferentTenant(): void + { + $assignment = $this->createAssignment(); + $this->repository->save($assignment); + + $result = $this->repository->findActiveByClass( + ClassId::fromString(self::CLASS_ID), + TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'), + ); + + self::assertEmpty($result); + } + + private function createAssignment( + ?string $teacherId = null, + ?string $subjectId = null, + ): TeacherAssignment { + return TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString($teacherId ?? self::TEACHER_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString($subjectId ?? self::SUBJECT_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/TeacherAssignmentVoterTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/TeacherAssignmentVoterTest.php new file mode 100644 index 0000000..9687ac9 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Security/TeacherAssignmentVoterTest.php @@ -0,0 +1,259 @@ +voter = new TeacherAssignmentVoter(); + } + + #[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, [TeacherAssignmentVoter::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, [TeacherAssignmentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + // --- VIEW --- + + #[Test] + #[DataProvider('viewAllowedRolesProvider')] + public function itGrantsViewToStaffRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [TeacherAssignmentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_GRANTED, $result); + } + + /** + * @return iterable + */ + public static function viewAllowedRolesProvider(): iterable + { + yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value]; + yield 'ADMIN' => [Role::ADMIN->value]; + yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value]; + yield 'SECRETARIAT' => [Role::SECRETARIAT->value]; + } + + #[Test] + public function itGrantsViewToTeacherForOwnResource(): void + { + $token = $this->tokenWithSecurityUser(Role::PROF->value, self::TEACHER_ID); + + $resource = new TeacherAssignmentResource(); + $resource->teacherId = self::TEACHER_ID; + + $result = $this->voter->vote($token, $resource, [TeacherAssignmentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToTeacherForOwnDomainModel(): void + { + $token = $this->tokenWithSecurityUser(Role::PROF->value, self::TEACHER_ID); + + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440040'), + createdAt: new DateTimeImmutable('2026-02-10 10:00:00'), + ); + + $result = $this->voter->vote($token, $assignment, [TeacherAssignmentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesViewToTeacherForOtherTeacherResource(): void + { + $token = $this->tokenWithSecurityUser(Role::PROF->value, self::TEACHER_ID); + + $resource = new TeacherAssignmentResource(); + $resource->teacherId = '550e8400-e29b-41d4-a716-446655440099'; + + $result = $this->voter->vote($token, $resource, [TeacherAssignmentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesViewToTeacherWithNoSubject(): void + { + $token = $this->tokenWithSecurityUser(Role::PROF->value, self::TEACHER_ID); + + $result = $this->voter->vote($token, null, [TeacherAssignmentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + #[Test] + #[DataProvider('viewDeniedRolesProvider')] + public function itDeniesViewToNonStaffRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [TeacherAssignmentVoter::VIEW]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + /** + * @return iterable + */ + public static function viewDeniedRolesProvider(): iterable + { + yield 'PARENT' => [Role::PARENT->value]; + yield 'ELEVE' => [Role::ELEVE->value]; + } + + // --- CREATE --- + + #[Test] + #[DataProvider('adminRolesProvider')] + public function itGrantsCreateToAdminRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [TeacherAssignmentVoter::CREATE]); + + self::assertSame(Voter::ACCESS_GRANTED, $result); + } + + #[Test] + #[DataProvider('nonAdminRolesProvider')] + public function itDeniesCreateToNonAdminRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [TeacherAssignmentVoter::CREATE]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + // --- DELETE --- + + #[Test] + #[DataProvider('adminRolesProvider')] + public function itGrantsDeleteToAdminRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [TeacherAssignmentVoter::DELETE]); + + self::assertSame(Voter::ACCESS_GRANTED, $result); + } + + #[Test] + #[DataProvider('nonAdminRolesProvider')] + public function itDeniesDeleteToNonAdminRoles(string $role): void + { + $token = $this->tokenWithSecurityUser($role); + + $result = $this->voter->vote($token, null, [TeacherAssignmentVoter::DELETE]); + + self::assertSame(Voter::ACCESS_DENIED, $result); + } + + // --- Data Providers --- + + /** + * @return iterable + */ + public static function adminRolesProvider(): iterable + { + yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value]; + yield 'ADMIN' => [Role::ADMIN->value]; + } + + /** + * @return iterable + */ + public static function nonAdminRolesProvider(): iterable + { + yield 'PROF' => [Role::PROF->value]; + yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value]; + yield 'SECRETARIAT' => [Role::SECRETARIAT->value]; + yield 'PARENT' => [Role::PARENT->value]; + yield 'ELEVE' => [Role::ELEVE->value]; + } + + private function tokenWithSecurityUser( + string $role, + string $userId = '550e8400-e29b-41d4-a716-446655440001', + ): TokenInterface { + $securityUser = new SecurityUser( + UserId::fromString($userId), + '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/backend/tests/Unit/Administration/Infrastructure/Service/RepositoryTeacherAssignmentCheckerTest.php b/backend/tests/Unit/Administration/Infrastructure/Service/RepositoryTeacherAssignmentCheckerTest.php new file mode 100644 index 0000000..0a17299 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Service/RepositoryTeacherAssignmentCheckerTest.php @@ -0,0 +1,107 @@ +repository = new InMemoryTeacherAssignmentRepository(); + $this->checker = new RepositoryTeacherAssignmentChecker($this->repository); + } + + #[Test] + public function estAffecteReturnsTrueWhenActiveAssignmentExists(): void + { + $this->createAndSaveAssignment(); + + self::assertTrue($this->checker->estAffecte( + UserId::fromString(self::TEACHER_ID), + ClassId::fromString(self::CLASS_ID), + SubjectId::fromString(self::SUBJECT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString(self::TENANT_ID), + )); + } + + #[Test] + public function estAffecteReturnsFalseWhenNoAssignment(): void + { + self::assertFalse($this->checker->estAffecte( + UserId::fromString(self::TEACHER_ID), + ClassId::fromString(self::CLASS_ID), + SubjectId::fromString(self::SUBJECT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString(self::TENANT_ID), + )); + } + + #[Test] + public function estAffecteReturnsFalseWhenAssignmentRemoved(): void + { + $assignment = $this->createAndSaveAssignment(); + $assignment->retirer(new DateTimeImmutable('2026-03-01 10:00:00')); + $this->repository->save($assignment); + + self::assertFalse($this->checker->estAffecte( + UserId::fromString(self::TEACHER_ID), + ClassId::fromString(self::CLASS_ID), + SubjectId::fromString(self::SUBJECT_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString(self::TENANT_ID), + )); + } + + #[Test] + public function estAffecteReturnsFalseForDifferentSubject(): void + { + $this->createAndSaveAssignment(); + + self::assertFalse($this->checker->estAffecte( + UserId::fromString(self::TEACHER_ID), + ClassId::fromString(self::CLASS_ID), + SubjectId::fromString('550e8400-e29b-41d4-a716-446655440099'), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + TenantId::fromString(self::TENANT_ID), + )); + } + + private function createAndSaveAssignment(): TeacherAssignment + { + $assignment = TeacherAssignment::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + createdAt: new DateTimeImmutable('2026-02-12 10:00:00'), + ); + + $this->repository->save($assignment); + + return $assignment; + } +} diff --git a/frontend/e2e/periods.spec.ts b/frontend/e2e/periods.spec.ts index d225c19..bfd1631 100644 --- a/frontend/e2e/periods.spec.ts +++ b/frontend/e2e/periods.spec.ts @@ -155,7 +155,7 @@ test.describe('Periods Management (Story 2.3)', () => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); - await expect(page.getByText(/trimestres/i)).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Trimestres', { exact: true })).toBeVisible({ timeout: 10000 }); }); test('shows dates on each period card', async ({ page }) => { diff --git a/frontend/e2e/teacher-assignments.spec.ts b/frontend/e2e/teacher-assignments.spec.ts new file mode 100644 index 0000000..0970f5b --- /dev/null +++ b/frontend/e2e/teacher-assignments.spec.ts @@ -0,0 +1,263 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-assignments-admin@example.com'; +const ADMIN_PASSWORD = 'AssignmentsTest123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +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' } + ); +} + +/** + * Resolve deterministic UUIDs matching backend resolvers (SchoolIdResolver, CurrentAcademicYearResolver). + * Without these, SQL-inserted test data won't be found by the API. + */ +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: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function waitForPageReady(page: import('@playwright/test').Page) { + await expect( + page.getByRole('heading', { name: /affectations enseignants/i }) + ).toBeVisible({ timeout: 15000 }); + // Wait for data loading to finish (either empty state or table appears) + await expect( + page.locator('.empty-state, .assignments-table, .alert-error') + ).toBeVisible({ timeout: 15000 }); +} + +async function openCreateDialog(page: import('@playwright/test').Page) { + // Use .first() because both the header and empty-state have a "Nouvelle affectation" button + const button = page.getByRole('button', { name: /nouvelle affectation/i }).first(); + await expect(button).toBeEnabled(); + await button.click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); +} + +async function createAssignmentViaUI(page: import('@playwright/test').Page) { + await waitForPageReady(page); + await openCreateDialog(page); + await page.locator('#assignment-teacher').selectOption({ index: 1 }); + await page.locator('#assignment-class').selectOption({ index: 1 }); + await page.locator('#assignment-subject').selectOption({ index: 1 }); + await page.getByRole('button', { name: /affecter/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); +} + +test.describe('Teacher Assignments (Story 2.8)', () => { + test.beforeAll(async () => { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-teacher-assign@example.com --password=TeacherTest123 --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + // Resolve deterministic IDs that match the backend resolvers + const { schoolId, academicYearId } = resolveDeterministicIds(); + + runCommand( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Assign-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + + runCommand( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Assign-Maths', 'E2EMATH', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + }); + + test.beforeEach(async () => { + runCommand(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`); + }); + + // ============================================================================ + // Navigation + // ============================================================================ + test.describe('Navigation', () => { + test('assignments link appears in admin navigation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin`); + + const navLink = page.getByRole('link', { name: /affectations/i }); + await expect(navLink).toBeVisible({ timeout: 15000 }); + }); + + test('can navigate to assignments page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + + await expect(page.getByRole('heading', { name: /affectations enseignants/i })).toBeVisible({ timeout: 15000 }); + }); + }); + + // ============================================================================ + // Empty State + // ============================================================================ + test.describe('Empty State', () => { + test('shows empty state when no assignments exist', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + + await waitForPageReady(page); + await expect(page.getByText(/aucune affectation/i)).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC1: Create Assignment + // ============================================================================ + test.describe('AC1: Create Assignment', () => { + test('can create a new assignment', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + await waitForPageReady(page); + + await openCreateDialog(page); + + const teacherSelect = page.locator('#assignment-teacher'); + await expect(teacherSelect).toBeVisible(); + const teacherOptions = teacherSelect.locator('option'); + const teacherCount = await teacherOptions.count(); + expect(teacherCount).toBeGreaterThan(1); + + await teacherSelect.selectOption({ index: 1 }); + + const classSelect = page.locator('#assignment-class'); + await expect(classSelect).toBeVisible(); + await classSelect.selectOption({ index: 1 }); + + const subjectSelect = page.locator('#assignment-subject'); + await expect(subjectSelect).toBeVisible(); + await subjectSelect.selectOption({ index: 1 }); + + await page.getByRole('button', { name: /affecter/i }).click(); + + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/affectation créée/i)).toBeVisible({ timeout: 10000 }); + + const table = page.locator('.assignments-table'); + await expect(table).toBeVisible({ timeout: 10000 }); + const rows = table.locator('tbody tr'); + const rowCount = await rows.count(); + expect(rowCount).toBeGreaterThanOrEqual(1); + }); + + test('shows error when creating duplicate assignment', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + + // Create first assignment + await createAssignmentViaUI(page); + + // Attempt to create the same assignment again + await openCreateDialog(page); + await page.locator('#assignment-teacher').selectOption({ index: 1 }); + await page.locator('#assignment-class').selectOption({ index: 1 }); + await page.locator('#assignment-subject').selectOption({ index: 1 }); + await page.getByRole('button', { name: /affecter/i }).click(); + + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); + }); + + test('cancel closes the modal without creating', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + await waitForPageReady(page); + + await openCreateDialog(page); + + await page.getByRole('button', { name: /annuler/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + }); + }); + + // ============================================================================ + // AC4: Remove Assignment + // ============================================================================ + test.describe('AC4: Remove Assignment', () => { + test('can remove an assignment', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + + await createAssignmentViaUI(page); + + const table = page.locator('.assignments-table'); + await expect(table).toBeVisible({ timeout: 10000 }); + + const removeButton = page.locator('.btn-remove').first(); + await removeButton.click(); + + const confirmDialog = page.getByRole('alertdialog'); + await expect(confirmDialog).toBeVisible({ timeout: 5000 }); + + await expect(page.getByText(/notes existantes seront conservées/i)).toBeVisible(); + + await confirmDialog.getByRole('button', { name: /retirer/i }).click(); + + await expect(confirmDialog).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/affectation retirée/i)).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC5: Class Detail - Teachers List + // ============================================================================ + test.describe('AC5: Class Detail Teachers', () => { + test('class detail page shows teachers section', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + + await createAssignmentViaUI(page); + + await page.goto(`${ALPHA_URL}/admin/classes`); + await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible({ timeout: 15000 }); + + const modifyButton = page.locator('.btn-secondary', { hasText: /modifier/i }).first(); + await modifyButton.click(); + await page.waitForURL(/\/admin\/classes\/[\w-]+/); + + await expect(page.getByRole('heading', { name: /enseignants affectés/i })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('link', { name: /gérer les affectations/i })).toBeVisible(); + }); + }); +}); diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index d7f2d21..05345c6 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -41,6 +41,11 @@ Gérer les matières Créer et gérer + + 📋 + Affectations + Enseignants et classes + 📅 Périodes scolaires diff --git a/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte b/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte index 66e97c5..5ac0546 100644 --- a/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte +++ b/frontend/src/lib/components/organisms/GuardianList/GuardianList.svelte @@ -192,8 +192,14 @@ {#if showAddModal} + + +
+
+

Enseignants affectés

+ Gérer les affectations +
+ + {#if teachersError} +
+ {teachersError} +
+ {/if} + + {#if isLoadingTeachers} +

Chargement des enseignants...

+ {:else if classTeachers.length === 0} +

Aucun enseignant affecté à cette classe.

+ {:else} +
    + {#each classTeachers as assignment (assignment.id)} +
  • + {getTeacherName(assignment.teacherId)} + {#if getSubjectColor(assignment.subjectId)} + + {getSubjectName(assignment.subjectId)} + + {:else} + + {getSubjectName(assignment.subjectId)} + + {/if} +
  • + {/each} +
+ {/if} +
{/if} diff --git a/frontend/src/routes/admin/subjects/[id]/+page.svelte b/frontend/src/routes/admin/subjects/[id]/+page.svelte index 80b7658..d56a6ad 100644 --- a/frontend/src/routes/admin/subjects/[id]/+page.svelte +++ b/frontend/src/routes/admin/subjects/[id]/+page.svelte @@ -124,7 +124,7 @@ successMessage = 'Matière mise à jour avec succès'; // Clear success message after 3 seconds - window.setTimeout(() => { + globalThis.setTimeout(() => { successMessage = null; }, 3000); } catch (e) { diff --git a/frontend/src/routes/admin/teachers/[id]/+page.svelte b/frontend/src/routes/admin/teachers/[id]/+page.svelte new file mode 100644 index 0000000..0540a41 --- /dev/null +++ b/frontend/src/routes/admin/teachers/[id]/+page.svelte @@ -0,0 +1,388 @@ + + + + {teacher ? `${teacher.firstName} ${teacher.lastName}` : 'Enseignant'} - Classeo + + +
+ {#if isLoading} +
+
+

Chargement du profil enseignant...

+
+ {:else if error} +
{error}
+ Retour aux utilisateurs + {:else if teacher} + + +
+

Informations

+
+
Email
+
{teacher.email}
+
Statut
+
{teacher.statut}
+
Roles
+
{teacher.roles.join(', ')}
+
+
+ +
+
+

Affectations ({activeAssignments.length})

+ Gerer les affectations +
+ + {#if activeAssignments.length === 0} +

Aucune affectation pour cet enseignant.

+ {:else} +
    + {#each activeAssignments as assignment (assignment.id)} +
  • +
    + + {getClassName(assignment.classId)} + {#if getClassLevel(assignment.classId)} + ({getClassLevel(assignment.classId)}) + {/if} + + {#if getSubjectColor(assignment.subjectId)} + + {getSubjectName(assignment.subjectId)} + + {:else} + {getSubjectName(assignment.subjectId)} + {/if} +
    + + Depuis le {new Date(assignment.startDate).toLocaleDateString('fr-FR')} + +
  • + {/each} +
+ {/if} +
+ {/if} +
+ + diff --git a/frontend/tests/unit/lib/auth/auth.test.ts b/frontend/tests/unit/lib/auth/auth.test.ts index 16a670b..55eae3c 100644 --- a/frontend/tests/unit/lib/auth/auth.test.ts +++ b/frontend/tests/unit/lib/auth/auth.test.ts @@ -48,6 +48,10 @@ describe('auth service', () => { vi.clearAllMocks(); vi.stubGlobal('fetch', vi.fn()); + // Silence expected console.error/warn from error-handling tests + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Re-mock goto for each test const navModule = await import('$app/navigation'); (navModule.goto as ReturnType).mockImplementation(mockGoto);