From 88e7f319db7ba72a87f8cf881eb3577330b13fe3 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Fri, 13 Feb 2026 20:22:39 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Affectation=20des=20enseignants=20aux?= =?UTF-8?q?=20classes=20et=20mati=C3=A8res?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permet aux administrateurs d'associer un enseignant à une classe pour une matière donnée au sein d'une année scolaire. Cette brique est nécessaire pour construire les emplois du temps et les carnets de notes par la suite. Le modèle impose l'unicité du triplet enseignant × classe × matière par année scolaire, avec réactivation automatique d'une affectation retirée plutôt que duplication. L'isolation multi-tenant est garantie au niveau du repository (findById/get filtrent par tenant_id). --- backend/config/services.yaml | 7 + backend/migrations/Version20260212100000.php | 55 ++ backend/migrations/Version20260212200000.php | 52 ++ .../AssignTeacher/AssignTeacherCommand.php | 17 + .../AssignTeacher/AssignTeacherHandler.php | 90 ++ .../RemoveAssignmentCommand.php | 14 + .../RemoveAssignmentHandler.php | 36 + .../Port/TeacherAssignmentChecker.php | 31 + .../GetAssignment/GetAssignmentHandler.php | 40 + .../GetAssignment/GetAssignmentQuery.php | 17 + .../GetAssignmentsForTeacherHandler.php | 32 + .../GetAssignmentsForTeacherQuery.php | 14 + .../TeacherAssignmentDto.php | 39 + .../GetTeachersForClassHandler.php | 33 + .../GetTeachersForClassQuery.php | 14 + .../Domain/Event/AffectationRetiree.php | 38 + .../Domain/Event/EnseignantAffecte.php | 38 + .../AffectationDejaExistanteException.php | 25 + .../AffectationNotFoundException.php | 21 + .../TeacherAssignment/AssignmentStatus.php | 24 + .../TeacherAssignment/TeacherAssignment.php | 166 ++++ .../TeacherAssignment/TeacherAssignmentId.php | 11 + .../TeacherAssignmentRepository.php | 70 ++ .../Api/Processor/CreateClassProcessor.php | 10 +- .../CreateTeacherAssignmentProcessor.php | 88 ++ .../RemoveTeacherAssignmentProcessor.php | 75 ++ .../Api/Provider/ClassCollectionProvider.php | 10 +- .../TeacherAssignmentItemProvider.php | 61 ++ .../TeacherAssignmentsByClassProvider.php | 67 ++ .../TeacherAssignmentsByTeacherProvider.php | 72 ++ .../Resource/TeacherAssignmentResource.php | 124 +++ .../DoctrineTeacherAssignmentRepository.php | 242 +++++ .../InMemoryTeacherAssignmentRepository.php | 137 +++ .../Security/TeacherAssignmentVoter.php | 150 +++ .../RepositoryTeacherAssignmentChecker.php | 41 + .../AssignTeacherHandlerTest.php | 286 ++++++ .../RemoveAssignmentHandlerTest.php | 121 +++ .../GetAssignmentHandlerTest.php | 126 +++ .../GetAssignmentsForTeacherHandlerTest.php | 133 +++ .../GetTeachersForClassHandlerTest.php | 131 +++ .../TeacherAssignmentTest.php | 175 ++++ .../Processor/CreateClassProcessorTest.php | 11 +- .../CreateTeacherAssignmentProcessorTest.php | 294 ++++++ .../RemoveTeacherAssignmentProcessorTest.php | 192 ++++ .../TeacherAssignmentItemProviderTest.php | 151 +++ .../TeacherAssignmentsByClassProviderTest.php | 151 +++ ...eacherAssignmentsByTeacherProviderTest.php | 178 ++++ .../TeacherAssignmentResourceTest.php | 105 +++ ...nMemoryTeacherAssignmentRepositoryTest.php | 290 ++++++ .../Security/TeacherAssignmentVoterTest.php | 259 +++++ ...RepositoryTeacherAssignmentCheckerTest.php | 107 +++ frontend/e2e/periods.spec.ts | 2 +- frontend/e2e/teacher-assignments.spec.ts | 263 ++++++ .../organisms/Dashboard/DashboardAdmin.svelte | 5 + .../GuardianList/GuardianList.svelte | 10 +- frontend/src/routes/admin/+layout.svelte | 106 ++- .../src/routes/admin/assignments/+page.svelte | 884 ++++++++++++++++++ .../routes/admin/classes/[id]/+page.svelte | 201 +++- .../routes/admin/subjects/[id]/+page.svelte | 2 +- .../routes/admin/teachers/[id]/+page.svelte | 388 ++++++++ frontend/tests/unit/lib/auth/auth.test.ts | 4 + 61 files changed, 6484 insertions(+), 52 deletions(-) create mode 100644 backend/migrations/Version20260212100000.php create mode 100644 backend/migrations/Version20260212200000.php create mode 100644 backend/src/Administration/Application/Command/AssignTeacher/AssignTeacherCommand.php create mode 100644 backend/src/Administration/Application/Command/AssignTeacher/AssignTeacherHandler.php create mode 100644 backend/src/Administration/Application/Command/RemoveAssignment/RemoveAssignmentCommand.php create mode 100644 backend/src/Administration/Application/Command/RemoveAssignment/RemoveAssignmentHandler.php create mode 100644 backend/src/Administration/Application/Port/TeacherAssignmentChecker.php create mode 100644 backend/src/Administration/Application/Query/GetAssignment/GetAssignmentHandler.php create mode 100644 backend/src/Administration/Application/Query/GetAssignment/GetAssignmentQuery.php create mode 100644 backend/src/Administration/Application/Query/GetAssignmentsForTeacher/GetAssignmentsForTeacherHandler.php create mode 100644 backend/src/Administration/Application/Query/GetAssignmentsForTeacher/GetAssignmentsForTeacherQuery.php create mode 100644 backend/src/Administration/Application/Query/GetAssignmentsForTeacher/TeacherAssignmentDto.php create mode 100644 backend/src/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassHandler.php create mode 100644 backend/src/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassQuery.php create mode 100644 backend/src/Administration/Domain/Event/AffectationRetiree.php create mode 100644 backend/src/Administration/Domain/Event/EnseignantAffecte.php create mode 100644 backend/src/Administration/Domain/Exception/AffectationDejaExistanteException.php create mode 100644 backend/src/Administration/Domain/Exception/AffectationNotFoundException.php create mode 100644 backend/src/Administration/Domain/Model/TeacherAssignment/AssignmentStatus.php create mode 100644 backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignment.php create mode 100644 backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignmentId.php create mode 100644 backend/src/Administration/Domain/Repository/TeacherAssignmentRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/CreateTeacherAssignmentProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/RemoveTeacherAssignmentProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentItemProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByClassProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByTeacherProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/TeacherAssignmentResource.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherAssignmentRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepository.php create mode 100644 backend/src/Administration/Infrastructure/Security/TeacherAssignmentVoter.php create mode 100644 backend/src/Administration/Infrastructure/Service/RepositoryTeacherAssignmentChecker.php create mode 100644 backend/tests/Unit/Administration/Application/Command/AssignTeacher/AssignTeacherHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/RemoveAssignment/RemoveAssignmentHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/GetAssignment/GetAssignmentHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/GetAssignmentsForTeacher/GetAssignmentsForTeacherHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/GetTeachersForClass/GetTeachersForClassHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/TeacherAssignment/TeacherAssignmentTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/CreateTeacherAssignmentProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/RemoveTeacherAssignmentProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentItemProviderTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByClassProviderTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Provider/TeacherAssignmentsByTeacherProviderTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Resource/TeacherAssignmentResourceTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepositoryTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Security/TeacherAssignmentVoterTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Service/RepositoryTeacherAssignmentCheckerTest.php create mode 100644 frontend/e2e/teacher-assignments.spec.ts create mode 100644 frontend/src/routes/admin/assignments/+page.svelte create mode 100644 frontend/src/routes/admin/teachers/[id]/+page.svelte 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);