From 86d00ce7338f0b7fafae5fc916a6d162674eac2d Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Tue, 21 Apr 2026 15:37:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Afficher=20les=20statistiques=20de=20no?= =?UTF-8?q?tes=20par=20mati=C3=A8re=20c=C3=B4t=C3=A9=20administration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'admin doit pouvoir voir en un coup d'œil quelles matières sont actives (notes saisies) pour décider lesquelles peuvent être supprimées sans perte de données. Auparavant, la suppression d'une matière était silencieuse : elle cascade-deletait évaluations et notes sans avertir. La liste des matières affiche désormais les compteurs d'enseignants, classes, évaluations et notes. La suppression déclenche une confirmation explicite quand la matière contient des notes, avec récapitulatif des volumes impactés, pour rendre l'action irréversible consciente. Côté tests, un endpoint de seeding HTTP remplace les appels docker exec dans les E2E (gain ~30-60s → 5-10s par test), et un trait partagé factorise le SQL de seeding entre les deux suites fonctionnelles. --- .../sprint-status.yaml | 5 +- backend/config/services.yaml | 4 + .../Application/Port/SubjectGradeStats.php | 22 ++ .../Port/SubjectGradeStatsReader.php | 21 ++ .../GetSubjectGradeStatsHandler.php | 28 ++ .../GetSubjectGradeStatsQuery.php | 19 ++ .../Query/GetSubjects/SubjectDto.php | 11 + .../Api/Processor/DeleteSubjectProcessor.php | 50 +++- .../Api/Resource/SubjectResource.php | 15 + .../ReadModel/DbalPaginatedSubjectsReader.php | 10 +- .../TestSeedSubjectWithGradesController.php | 277 ++++++++++++++++++ .../DoctrineSubjectGradeStatsReader.php | 54 ++++ .../DbalPaginatedSubjectsReaderTest.php | 136 +++++++++ .../Helpers/SubjectStatsSeedingTrait.php | 174 +++++++++++ .../DoctrineSubjectGradeStatsReaderTest.php | 118 ++++++++ .../GetSubjectGradeStatsHandlerTest.php | 128 ++++++++ .../Processor/DeleteSubjectProcessorTest.php | 232 +++++++++++++++ frontend/e2e/subjects.spec.ts | 100 ++++++- .../features/subjects/api/deleteSubject.ts | 72 +++++ .../src/routes/admin/subjects/+page.svelte | 86 ++++-- .../subjects/api/deleteSubject.test.ts | 82 ++++++ 21 files changed, 1602 insertions(+), 42 deletions(-) create mode 100644 backend/src/Administration/Application/Port/SubjectGradeStats.php create mode 100644 backend/src/Administration/Application/Port/SubjectGradeStatsReader.php create mode 100644 backend/src/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsHandler.php create mode 100644 backend/src/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsQuery.php create mode 100644 backend/src/Scolarite/Infrastructure/Controller/TestSeedSubjectWithGradesController.php create mode 100644 backend/src/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReader.php create mode 100644 backend/tests/Functional/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReaderTest.php create mode 100644 backend/tests/Functional/Helpers/SubjectStatsSeedingTrait.php create mode 100644 backend/tests/Functional/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReaderTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessorTest.php create mode 100644 frontend/src/lib/features/subjects/api/deleteSubject.ts create mode 100644 frontend/tests/unit/lib/features/subjects/api/deleteSubject.test.ts diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 5e101d6..df95f54 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -38,6 +38,7 @@ project: classeo project_key: classeo tracking_system: file-system story_location: _bmad-output/implementation-artifacts +last_updated: 2026-04-17 development_status: # Epic 1: Fondations, Auth & Observabilité (9 stories) @@ -121,8 +122,8 @@ development_status: 6-6-consultation-notes-par-leleve: done 6-7-consultation-notes-par-le-parent: done 6-8-statistiques-enseignant: done - 6-9-grade-voter-et-acces-notes-affectations: review # Débloque tâches différées de 2-6, 2-8, 2-9 - 6-10-statistiques-notes-par-matiere-admin: ready-for-dev # Débloque tâches différées de 2-2 + 6-9-grade-voter-et-acces-notes-affectations: done # Débloque tâches différées de 2-6, 2-8, 2-9 + 6-10-statistiques-notes-par-matiere-admin: review # Débloque tâches différées de 2-2 6-11-audit-trail-evenements-notes: ready-for-dev # Débloque tâches différées de 1-7 6-12-correctifs-mode-competences: ready-for-dev # Patches critiques review 6-5 6-13-acces-evaluations-remplacant: ready-for-dev # UX : navigation évaluations pour le remplaçant (identifié en 6-9) diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 4d1ee82..b25f37a 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -451,6 +451,10 @@ services: App\Administration\Application\Port\GradeExistenceChecker: alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker + # SubjectGradeStatsReader (implémentation Scolarite via SQL) + App\Administration\Application\Port\SubjectGradeStatsReader: + alias: App\Scolarite\Infrastructure\Service\DoctrineSubjectGradeStatsReader + # ActiveRoleStore (session-scoped cache for active role switching) App\Administration\Application\Port\ActiveRoleStore: alias: App\Administration\Infrastructure\Service\CacheActiveRoleStore diff --git a/backend/src/Administration/Application/Port/SubjectGradeStats.php b/backend/src/Administration/Application/Port/SubjectGradeStats.php new file mode 100644 index 0000000..f43b436 --- /dev/null +++ b/backend/src/Administration/Application/Port/SubjectGradeStats.php @@ -0,0 +1,22 @@ +gradeCount > 0 || $this->evaluationCount > 0; + } +} diff --git a/backend/src/Administration/Application/Port/SubjectGradeStatsReader.php b/backend/src/Administration/Application/Port/SubjectGradeStatsReader.php new file mode 100644 index 0000000..00cd3a5 --- /dev/null +++ b/backend/src/Administration/Application/Port/SubjectGradeStatsReader.php @@ -0,0 +1,21 @@ +reader->countForSubject( + TenantId::fromString($query->tenantId), + SubjectId::fromString($query->subjectId), + ); + } +} diff --git a/backend/src/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsQuery.php b/backend/src/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsQuery.php new file mode 100644 index 0000000..6fa2f8f --- /dev/null +++ b/backend/src/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsQuery.php @@ -0,0 +1,19 @@ +id, @@ -42,6 +46,13 @@ final readonly class SubjectDto updatedAt: $subject->updatedAt, teacherCount: $teacherCount, classCount: $classCount, + evaluationCount: $evaluationCount, + gradeCount: $gradeCount, ); } + + public function hasGrades(): bool + { + return $this->evaluationCount > 0 || $this->gradeCount > 0; + } } diff --git a/backend/src/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessor.php index 61a9e35..50be183 100644 --- a/backend/src/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessor.php +++ b/backend/src/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessor.php @@ -8,13 +8,20 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectCommand; use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectHandler; +use App\Administration\Application\Query\GetSubjectGradeStats\GetSubjectGradeStatsHandler; +use App\Administration\Application\Query\GetSubjectGradeStats\GetSubjectGradeStatsQuery; use App\Administration\Domain\Exception\SubjectNotFoundException; use App\Administration\Infrastructure\Api\Resource\SubjectResource; use App\Administration\Infrastructure\Security\SubjectVoter; use App\Shared\Infrastructure\Tenant\TenantContext; use Override; use Ramsey\Uuid\Exception\InvalidUuidStringException; + +use function sprintf; + +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Messenger\MessageBusInterface; @@ -23,9 +30,15 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; /** * Processor API Platform pour supprimer (archiver) une matière. * - * Note: Cette implémentation fait un soft delete (archivage). - * La vérification des notes associées (T6) sera ajoutée ultérieurement - * quand le module Notes sera implémenté. + * Soft delete (archivage). Si des évaluations ou notes sont liées, une confirmation + * explicite est requise via le paramètre `?confirm=true` afin que l'admin ait + * conscience de l'impact. + * + * TOCTOU : la lecture des stats et l'archivage ne sont pas atomiques. Une évaluation + * peut être créée entre la vérification et l'archive — acceptable car : + * 1. L'archive est réversible (flag `deleted_at`, pas de DROP de données) + * 2. Les évaluations/notes créées pendant la fenêtre restent consultables via l'historique + * 3. L'impact affiché à l'admin est au pire sous-estimé, jamais sur-estimé * * @implements ProcessorInterface */ @@ -33,9 +46,11 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface { public function __construct( private ArchiveSubjectHandler $handler, + private GetSubjectGradeStatsHandler $gradeStatsHandler, private TenantContext $tenantContext, private MessageBusInterface $eventBus, private AuthorizationCheckerInterface $authorizationChecker, + private RequestStack $requestStack, ) { } @@ -62,8 +77,20 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface $tenantId = (string) $this->tenantContext->getCurrentTenantId(); try { - // TODO: Vérifier si des notes sont associées (T6) - // et retourner un warning si c'est le cas (via query param ?confirm=true) + if (!$this->isConfirmed()) { + $stats = ($this->gradeStatsHandler)(new GetSubjectGradeStatsQuery( + tenantId: $tenantId, + subjectId: $subjectId, + )); + + if ($stats->hasGrades()) { + throw new ConflictHttpException(sprintf( + 'Cette matière est liée à %d évaluation(s) et %d note(s). Confirmez la suppression pour continuer.', + $stats->evaluationCount, + $stats->gradeCount, + )); + } + } $command = new ArchiveSubjectCommand( subjectId: $subjectId, @@ -72,7 +99,7 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface $subject = ($this->handler)($command); - // Dispatch domain events from the archived aggregate + // Propage les événements domaine (MatiereSupprimee, etc.) émis par l'agrégat. foreach ($subject->pullDomainEvents() as $event) { $this->eventBus->dispatch($event); } @@ -82,4 +109,15 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface throw new NotFoundHttpException('Matière non trouvée.'); } } + + private function isConfirmed(): bool + { + $request = $this->requestStack->getCurrentRequest(); + + if ($request === null) { + return false; + } + + return $request->query->getBoolean('confirm'); + } } diff --git a/backend/src/Administration/Infrastructure/Api/Resource/SubjectResource.php b/backend/src/Administration/Infrastructure/Api/Resource/SubjectResource.php index b49a843..266e6ad 100644 --- a/backend/src/Administration/Infrastructure/Api/Resource/SubjectResource.php +++ b/backend/src/Administration/Infrastructure/Api/Resource/SubjectResource.php @@ -110,6 +110,18 @@ final class SubjectResource #[ApiProperty(readable: true, writable: false)] public ?int $classCount = null; + /** + * Statistiques : nombre d'évaluations créées pour cette matière. + */ + #[ApiProperty(readable: true, writable: false)] + public ?int $evaluationCount = null; + + /** + * Statistiques : nombre de notes saisies pour cette matière. + */ + #[ApiProperty(readable: true, writable: false)] + public ?int $gradeCount = null; + /** * Permet de supprimer explicitement la couleur lors d'un PATCH. * Si true, la couleur sera mise à null même si color n'est pas fourni. @@ -164,6 +176,9 @@ final class SubjectResource $resource->updatedAt = $dto->updatedAt; $resource->teacherCount = $dto->teacherCount; $resource->classCount = $dto->classCount; + $resource->evaluationCount = $dto->evaluationCount; + $resource->gradeCount = $dto->gradeCount; + $resource->hasGrades = $dto->hasGrades(); return $resource; } diff --git a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php index 5eb074e..fcde8ea 100644 --- a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php +++ b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php @@ -52,7 +52,9 @@ final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsRea s.id, s.name, s.code, s.color, s.description, s.status, s.created_at, s.updated_at, (SELECT COUNT(*) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS teacher_count, - (SELECT COUNT(DISTINCT ta.school_class_id) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS class_count + (SELECT COUNT(DISTINCT ta.school_class_id) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS class_count, + (SELECT COUNT(*) FROM evaluations e WHERE e.subject_id = s.id AND e.tenant_id = s.tenant_id) AS evaluation_count, + (SELECT COUNT(g.id) FROM grades g INNER JOIN evaluations e ON e.id = g.evaluation_id WHERE e.subject_id = s.id AND e.tenant_id = s.tenant_id AND g.tenant_id = s.tenant_id) AS grade_count FROM subjects s WHERE {$whereClause} ORDER BY s.name ASC @@ -85,6 +87,10 @@ final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsRea $teacherCountRaw = $row['teacher_count'] ?? 0; /** @var int|string $classCountRaw */ $classCountRaw = $row['class_count'] ?? 0; + /** @var int|string $evaluationCountRaw */ + $evaluationCountRaw = $row['evaluation_count'] ?? 0; + /** @var int|string $gradeCountRaw */ + $gradeCountRaw = $row['grade_count'] ?? 0; return new SubjectDto( id: $id, @@ -97,6 +103,8 @@ final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsRea updatedAt: new DateTimeImmutable($updatedAt), teacherCount: (int) $teacherCountRaw, classCount: (int) $classCountRaw, + evaluationCount: (int) $evaluationCountRaw, + gradeCount: (int) $gradeCountRaw, ); }, $rows); diff --git a/backend/src/Scolarite/Infrastructure/Controller/TestSeedSubjectWithGradesController.php b/backend/src/Scolarite/Infrastructure/Controller/TestSeedSubjectWithGradesController.php new file mode 100644 index 0000000..4e75876 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Controller/TestSeedSubjectWithGradesController.php @@ -0,0 +1,277 @@ +requireTenantId(); + $payload = $this->decodeJson($request->getContent()); + + $subjectId = $this->requireString($payload, 'subjectId'); + $teacherEmail = $this->requireString($payload, 'teacherEmail'); + $evaluationCount = $this->optionalInt($payload, 'evaluationCount', 2); + $gradesPerEval = $this->optionalInt($payload, 'gradesPerEval', 1); + + if ($evaluationCount < 1 || $gradesPerEval < 0) { + throw new BadRequestHttpException('evaluationCount must be >= 1 and gradesPerEval >= 0'); + } + + $teacherId = $this->resolveTeacherId($tenantId, $teacherEmail); + $classId = $this->findOrCreateClass($tenantId); + + $evaluationIds = []; + for ($i = 1; $i <= $evaluationCount; ++$i) { + $evaluationIds[] = $this->insertEvaluation( + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: sprintf('Eval %d', $i), + ); + } + + $gradeIds = []; + foreach ($evaluationIds as $evaluationId) { + for ($j = 1; $j <= $gradesPerEval; ++$j) { + $gradeIds[] = $this->insertGrade( + tenantId: $tenantId, + evaluationId: $evaluationId, + studentId: $teacherId, + value: 10.0 + $j, + ); + } + } + + return new JsonResponse([ + 'classId' => $classId, + 'evaluationIds' => $evaluationIds, + 'gradeIds' => $gradeIds, + ]); + } + + #[Route( + '/test/seed/subject-with-grades/{subjectId}', + name: 'test_seed_subject_with_grades_cleanup', + requirements: ['subjectId' => '[0-9a-f-]{36}'], + methods: ['DELETE'], + )] + public function cleanup(string $subjectId): JsonResponse + { + $tenantId = $this->requireTenantId(); + + $this->connection->executeStatement( + 'DELETE FROM grades + WHERE tenant_id = :tenant + AND evaluation_id IN ( + SELECT id FROM evaluations WHERE tenant_id = :tenant AND subject_id = :subject + )', + ['tenant' => $tenantId, 'subject' => $subjectId], + ); + + $this->connection->executeStatement( + 'DELETE FROM evaluations WHERE tenant_id = :tenant AND subject_id = :subject', + ['tenant' => $tenantId, 'subject' => $subjectId], + ); + + return new JsonResponse(null, 204); + } + + private function requireTenantId(): string + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + return (string) $this->tenantContext->getCurrentTenantId(); + } + + /** + * @return array + */ + private function decodeJson(string $raw): array + { + if ($raw === '') { + return []; + } + + try { + $decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new BadRequestHttpException('Invalid JSON payload: ' . $e->getMessage(), $e); + } + + if (!is_array($decoded)) { + throw new BadRequestHttpException('Payload must be a JSON object'); + } + + $result = []; + foreach ($decoded as $key => $value) { + if (!is_string($key)) { + throw new BadRequestHttpException('Payload must be a JSON object (got a list)'); + } + $result[$key] = $value; + } + + return $result; + } + + /** + * @param array $payload + */ + private function requireString(array $payload, string $key): string + { + $value = $payload[$key] ?? null; + if (!is_string($value) || $value === '') { + throw new BadRequestHttpException(sprintf('%s required (non-empty string)', $key)); + } + + return $value; + } + + /** + * @param array $payload + */ + private function optionalInt(array $payload, string $key, int $default): int + { + $value = $payload[$key] ?? $default; + if (!is_int($value)) { + throw new BadRequestHttpException(sprintf('%s must be an integer', $key)); + } + + return $value; + } + + private function resolveTeacherId(string $tenantId, string $email): string + { + /** @var string|false $teacherId */ + $teacherId = $this->connection->fetchOne( + 'SELECT id FROM users WHERE tenant_id = :tenant AND email = :email LIMIT 1', + ['tenant' => $tenantId, 'email' => $email], + ); + + if ($teacherId === false) { + throw new BadRequestHttpException(sprintf( + 'No user found for email "%s" in current tenant.', + $email, + )); + } + + return $teacherId; + } + + private function findOrCreateClass(string $tenantId): string + { + /** @var string|false $existing */ + $existing = $this->connection->fetchOne( + 'SELECT id FROM school_classes WHERE tenant_id = :tenant LIMIT 1', + ['tenant' => $tenantId], + ); + + if ($existing !== false) { + return $existing; + } + + $classId = Uuid::uuid4()->toString(); + $this->connection->executeStatement( + "INSERT INTO school_classes + (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) + VALUES (:id, :tenant, :tenant, :tenant, 'Classe E2E', '6e', 'active', NOW(), NOW())", + ['id' => $classId, 'tenant' => $tenantId], + ); + + return $classId; + } + + private function insertEvaluation( + string $tenantId, + string $classId, + string $subjectId, + string $teacherId, + string $title, + ): string { + $id = Uuid::uuid4()->toString(); + $this->connection->executeStatement( + 'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date) + VALUES (:id, :tenant, :class, :subject, :teacher, :title, CURRENT_DATE)', + [ + 'id' => $id, + 'tenant' => $tenantId, + 'class' => $classId, + 'subject' => $subjectId, + 'teacher' => $teacherId, + 'title' => $title, + ], + ); + + return $id; + } + + private function insertGrade( + string $tenantId, + string $evaluationId, + string $studentId, + float $value, + ): string { + $id = Uuid::uuid4()->toString(); + $this->connection->executeStatement( + 'INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, created_by) + VALUES (:id, :tenant, :eval, :student, :value, :student)', + [ + 'id' => $id, + 'tenant' => $tenantId, + 'eval' => $evaluationId, + 'student' => $studentId, + 'value' => $value, + ], + ); + + return $id; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReader.php b/backend/src/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReader.php new file mode 100644 index 0000000..e6a9fa9 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReader.php @@ -0,0 +1,54 @@ +connection->fetchOne( + 'SELECT COUNT(*) FROM evaluations WHERE subject_id = :subject_id AND tenant_id = :tenant_id', + [ + 'subject_id' => (string) $subjectId, + 'tenant_id' => (string) $tenantId, + ], + ); + + /** @var int|string|false $gradeCountRaw */ + $gradeCountRaw = $this->connection->fetchOne( + 'SELECT COUNT(g.id) + FROM grades g + INNER JOIN evaluations e ON e.id = g.evaluation_id + WHERE e.subject_id = :subject_id + AND e.tenant_id = :tenant_id + AND g.tenant_id = :tenant_id', + [ + 'subject_id' => (string) $subjectId, + 'tenant_id' => (string) $tenantId, + ], + ); + + return new SubjectGradeStats( + evaluationCount: (int) $evaluationCountRaw, + gradeCount: (int) $gradeCountRaw, + ); + } +} diff --git a/backend/tests/Functional/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReaderTest.php b/backend/tests/Functional/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReaderTest.php new file mode 100644 index 0000000..64765e6 --- /dev/null +++ b/backend/tests/Functional/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReaderTest.php @@ -0,0 +1,136 @@ +get(Connection::class); + $this->sharedConnection = $connection; + + /** @var PaginatedSubjectsReader $reader */ + $reader = static::getContainer()->get(PaginatedSubjectsReader::class); + $this->reader = $reader; + } + + protected function tearDown(): void + { + $this->cleanupSubjectStatsData([self::TENANT_A, self::TENANT_B]); + + parent::tearDown(); + } + + protected function connection(): Connection + { + return $this->sharedConnection; + } + + #[Test] + public function paginatedResultExposesZeroStatsWhenSubjectHasNoData(): void + { + $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Matière vide', 'EMPTY'); + + $result = $this->reader->findPaginated( + tenantId: self::TENANT_A, + schoolId: self::SCHOOL_ID, + search: 'Matière vide', + page: 1, + limit: 30, + ); + + self::assertCount(1, $result->items); + $dto = $result->items[0]; + self::assertSame('Matière vide', $dto->name); + self::assertSame(0, $dto->teacherCount); + self::assertSame(0, $dto->classCount); + self::assertSame(0, $dto->evaluationCount); + self::assertSame(0, $dto->gradeCount); + self::assertFalse($dto->hasGrades()); + } + + #[Test] + public function paginatedResultCountsEvaluationsGradesTeachersAndClassesPerSubject(): void + { + $subjectId = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Mathématiques', 'MATH2'); + $classId = $this->insertClass(self::TENANT_A, self::SCHOOL_ID); + $teacherId = $this->insertUser(self::TENANT_A, 'paginated-teacher@test.local'); + $studentId = $this->insertUser(self::TENANT_A, 'paginated-student@test.local'); + + $this->insertTeacherAssignment(self::TENANT_A, $teacherId, $classId, $subjectId, self::SCHOOL_ID); + + $eval1 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Eval 1'); + $eval2 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Eval 2'); + $this->insertGrade(self::TENANT_A, $eval1, $studentId, 15.0); + $this->insertGrade(self::TENANT_A, $eval2, $studentId, 18.0); + + $result = $this->reader->findPaginated( + tenantId: self::TENANT_A, + schoolId: self::SCHOOL_ID, + search: 'Mathématiques', + page: 1, + limit: 30, + ); + + self::assertCount(1, $result->items); + $dto = $result->items[0]; + self::assertSame(1, $dto->teacherCount); + self::assertSame(1, $dto->classCount); + self::assertSame(2, $dto->evaluationCount); + self::assertSame(2, $dto->gradeCount); + self::assertTrue($dto->hasGrades()); + } + + #[Test] + public function paginatedResultDoesNotLeakStatsFromOtherTenants(): void + { + $subjectA = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Isolation', 'ISO'); + + // Données tenant B avec le même nom de matière + $subjectB = $this->insertSubject(self::TENANT_B, self::SCHOOL_ID, 'Isolation', 'ISO'); + $classB = $this->insertClass(self::TENANT_B, self::SCHOOL_ID); + $teacherB = $this->insertUser(self::TENANT_B, 'isolation-teacher@test.local'); + $studentB = $this->insertUser(self::TENANT_B, 'isolation-student@test.local'); + $this->insertTeacherAssignment(self::TENANT_B, $teacherB, $classB, $subjectB, self::SCHOOL_ID); + $evalB = $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B'); + $this->insertGrade(self::TENANT_B, $evalB, $studentB, 10.0); + + $resultA = $this->reader->findPaginated( + tenantId: self::TENANT_A, + schoolId: self::SCHOOL_ID, + search: 'Isolation', + page: 1, + limit: 30, + ); + + self::assertCount(1, $resultA->items); + $dto = $resultA->items[0]; + self::assertSame($subjectA, $dto->id); + self::assertSame(0, $dto->teacherCount); + self::assertSame(0, $dto->classCount); + self::assertSame(0, $dto->evaluationCount); + self::assertSame(0, $dto->gradeCount); + } +} diff --git a/backend/tests/Functional/Helpers/SubjectStatsSeedingTrait.php b/backend/tests/Functional/Helpers/SubjectStatsSeedingTrait.php new file mode 100644 index 0000000..ab4c75a --- /dev/null +++ b/backend/tests/Functional/Helpers/SubjectStatsSeedingTrait.php @@ -0,0 +1,174 @@ +toString(); + $this->connection()->executeStatement( + "INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) + VALUES (:id, :tenant, :school, :name, :code, 'active', NOW(), NOW())", + [ + 'id' => $id, + 'tenant' => $tenantId, + 'school' => $schoolId, + 'name' => $name, + 'code' => $code, + ], + ); + + return $id; + } + + protected function insertClass(string $tenantId, string $schoolId): string + { + $id = Uuid::uuid4()->toString(); + $this->connection()->executeStatement( + "INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) + VALUES (:id, :tenant, :school, :year, '6e A', '6e', 'active', NOW(), NOW())", + [ + 'id' => $id, + 'tenant' => $tenantId, + 'school' => $schoolId, + 'year' => $schoolId, + ], + ); + + return $id; + } + + protected function insertUser(string $tenantId, string $email): string + { + $id = Uuid::uuid4()->toString(); + $this->connection()->executeStatement( + "INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, statut, school_name, image_rights_status, created_at, updated_at) + VALUES (:id, :tenant, :email, 'Test', 'User', '[\"ROLE_USER\"]', 'active', 'Test School', 'not_requested', NOW(), NOW())", + [ + 'id' => $id, + 'tenant' => $tenantId, + 'email' => $email, + ], + ); + + return $id; + } + + protected function insertTeacherAssignment( + string $tenantId, + string $teacherId, + string $classId, + string $subjectId, + string $academicYearId, + ): void { + $this->connection()->executeStatement( + "INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, start_date, status, created_at, updated_at) + VALUES (:id, :tenant, :teacher, :class, :subject, :year, NOW(), 'active', NOW(), NOW())", + [ + 'id' => Uuid::uuid4()->toString(), + 'tenant' => $tenantId, + 'teacher' => $teacherId, + 'class' => $classId, + 'subject' => $subjectId, + 'year' => $academicYearId, + ], + ); + } + + protected function insertEvaluation( + string $tenantId, + string $classId, + string $subjectId, + string $teacherId, + string $title, + ): string { + $id = Uuid::uuid4()->toString(); + $this->connection()->executeStatement( + 'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date) + VALUES (:id, :tenant, :class, :subject, :teacher, :title, CURRENT_DATE)', + [ + 'id' => $id, + 'tenant' => $tenantId, + 'class' => $classId, + 'subject' => $subjectId, + 'teacher' => $teacherId, + 'title' => $title, + ], + ); + + return $id; + } + + protected function insertGrade(string $tenantId, string $evaluationId, string $studentId, float $value): void + { + $this->connection()->executeStatement( + 'INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, created_by) + VALUES (:id, :tenant, :eval, :student, :value, :student)', + [ + 'id' => Uuid::uuid4()->toString(), + 'tenant' => $tenantId, + 'eval' => $evaluationId, + 'student' => $studentId, + 'value' => $value, + ], + ); + } + + /** + * Purge les tables de seeding pour les tenants donnés. + * + * L'ordre respecte les clés étrangères (grades → evaluations → teacher_assignments → subjects → school_classes → users). + * + * @param list $tenantIds + */ + protected function cleanupSubjectStatsData(array $tenantIds): void + { + if ($tenantIds === []) { + return; + } + + $placeholders = implode(',', array_map(static fn (int $i) => ':tenant_' . $i, array_keys($tenantIds))); + $params = []; + foreach ($tenantIds as $i => $tenantId) { + $params['tenant_' . $i] = $tenantId; + } + + foreach ( + [ + 'grades', + 'evaluations', + 'teacher_assignments', + 'subjects', + 'school_classes', + 'users', + ] as $table + ) { + $this->connection()->executeStatement( + sprintf('DELETE FROM %s WHERE tenant_id IN (%s)', $table, $placeholders), + $params, + ); + } + } +} diff --git a/backend/tests/Functional/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReaderTest.php b/backend/tests/Functional/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReaderTest.php new file mode 100644 index 0000000..0c19a14 --- /dev/null +++ b/backend/tests/Functional/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReaderTest.php @@ -0,0 +1,118 @@ +get(Connection::class); + $this->sharedConnection = $connection; + + /** @var SubjectGradeStatsReader $reader */ + $reader = static::getContainer()->get(SubjectGradeStatsReader::class); + $this->reader = $reader; + } + + protected function tearDown(): void + { + $this->cleanupSubjectStatsData([self::TENANT_A, self::TENANT_B]); + + parent::tearDown(); + } + + protected function connection(): Connection + { + return $this->sharedConnection; + } + + #[Test] + public function returnsZeroStatsWhenSubjectHasNoEvaluation(): void + { + $subjectId = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Sans évaluation', 'EMPTY'); + + $stats = $this->reader->countForSubject( + TenantId::fromString(self::TENANT_A), + SubjectId::fromString($subjectId), + ); + + self::assertSame(0, $stats->evaluationCount); + self::assertSame(0, $stats->gradeCount); + self::assertFalse($stats->hasGrades()); + } + + #[Test] + public function countsEvaluationsAndGradesLinkedToSubject(): void + { + $subjectId = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Maths', 'MATH'); + $classId = $this->insertClass(self::TENANT_A, self::SCHOOL_ID); + $teacherId = $this->insertUser(self::TENANT_A, 'teacher-math@test.local'); + $studentAId = $this->insertUser(self::TENANT_A, 'student-a@test.local'); + $studentBId = $this->insertUser(self::TENANT_A, 'student-b@test.local'); + + $eval1 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Contrôle 1'); + $eval2 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Contrôle 2'); + + $this->insertGrade(self::TENANT_A, $eval1, $studentAId, 15.0); + $this->insertGrade(self::TENANT_A, $eval1, $studentBId, 12.5); + $this->insertGrade(self::TENANT_A, $eval2, $studentAId, 18.0); + + $stats = $this->reader->countForSubject( + TenantId::fromString(self::TENANT_A), + SubjectId::fromString($subjectId), + ); + + self::assertSame(2, $stats->evaluationCount); + self::assertSame(3, $stats->gradeCount); + self::assertTrue($stats->hasGrades()); + } + + #[Test] + public function doesNotCountDataFromOtherTenants(): void + { + $subjectA = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Histoire', 'HIST'); + $subjectB = $this->insertSubject(self::TENANT_B, self::SCHOOL_ID, 'Histoire', 'HIST'); + + // Tenant B seed : 3 évaluations + 2 notes sur son subject + $classB = $this->insertClass(self::TENANT_B, self::SCHOOL_ID); + $teacherB = $this->insertUser(self::TENANT_B, 'teacher-b@test.local'); + $studentB = $this->insertUser(self::TENANT_B, 'student-b-isolation@test.local'); + $evalB1 = $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B1'); + $evalB2 = $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B2'); + $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B3'); + $this->insertGrade(self::TENANT_B, $evalB1, $studentB, 10.0); + $this->insertGrade(self::TENANT_B, $evalB2, $studentB, 14.0); + + $statsA = $this->reader->countForSubject( + TenantId::fromString(self::TENANT_A), + SubjectId::fromString($subjectA), + ); + + self::assertSame(0, $statsA->evaluationCount, 'Pas de fuite des évaluations du tenant B'); + self::assertSame(0, $statsA->gradeCount, 'Pas de fuite des notes du tenant B'); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsHandlerTest.php new file mode 100644 index 0000000..803dcb5 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsHandlerTest.php @@ -0,0 +1,128 @@ +createReader(evaluations: 0, grades: 0)); + + $stats = $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(0, $stats->evaluationCount); + self::assertSame(0, $stats->gradeCount); + self::assertFalse($stats->hasGrades()); + } + + #[Test] + public function itReturnsCountsWhenEvaluationsExist(): void + { + $handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 3, grades: 42)); + + $stats = $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(3, $stats->evaluationCount); + self::assertSame(42, $stats->gradeCount); + self::assertTrue($stats->hasGrades()); + } + + #[Test] + public function itConsidersSubjectWithEvaluationsButNoGradesAsHavingImpact(): void + { + $handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 2, grades: 0)); + + $stats = $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(2, $stats->evaluationCount); + self::assertSame(0, $stats->gradeCount); + self::assertTrue($stats->hasGrades(), 'Une évaluation sans notes reste un impact à signaler.'); + } + + #[Test] + public function itConsidersSubjectWithGradesButNoEvaluationsAsHavingImpact(): void + { + // Théoriquement impossible via la FK grades.evaluation_id → evaluations(id), + // mais on couvre la logique `||` du value object contre toute régression. + $handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 0, grades: 5)); + + $stats = $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(0, $stats->evaluationCount); + self::assertSame(5, $stats->gradeCount); + self::assertTrue($stats->hasGrades()); + } + + #[Test] + public function itPassesQueryParamsToReader(): void + { + $reader = new class implements SubjectGradeStatsReader { + public ?string $receivedTenantId = null; + public ?string $receivedSubjectId = null; + + #[Override] + public function countForSubject(TenantId $tenantId, SubjectId $subjectId): SubjectGradeStats + { + $this->receivedTenantId = (string) $tenantId; + $this->receivedSubjectId = (string) $subjectId; + + return new SubjectGradeStats(0, 0); + } + }; + + $handler = new GetSubjectGradeStatsHandler($reader); + + $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(self::TENANT_ID, $reader->receivedTenantId); + self::assertSame(self::SUBJECT_ID, $reader->receivedSubjectId); + } + + private function createReader(int $evaluations, int $grades): SubjectGradeStatsReader + { + return new class($evaluations, $grades) implements SubjectGradeStatsReader { + public function __construct( + private int $evaluations, + private int $grades, + ) { + } + + #[Override] + public function countForSubject(TenantId $tenantId, SubjectId $subjectId): SubjectGradeStats + { + return new SubjectGradeStats($this->evaluations, $this->grades); + } + }; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessorTest.php new file mode 100644 index 0000000..858d77e --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessorTest.php @@ -0,0 +1,232 @@ +subjectRepository = new InMemorySubjectRepository(); + $this->clock = new class implements Clock { + #[Override] + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-04-16 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 itDeletesSubjectWhenNoGradesExist(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(statsReader: $this->statsReader(0, 0)); + + $result = $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + + self::assertNull($result); + $reloaded = $this->subjectRepository->get($subject->id); + self::assertNotNull($reloaded->deletedAt); + } + + #[Test] + public function itThrowsConflictWhenGradesExistAndConfirmNotSet(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(statsReader: $this->statsReader(3, 42)); + + $this->expectException(ConflictHttpException::class); + $this->expectExceptionMessageMatches('/3 évaluation\(s\) et 42 note\(s\)/'); + + $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + } + + #[Test] + public function itDeletesSubjectWhenConfirmIsTrue(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor( + statsReader: $this->statsReader(3, 42), + request: new Request(query: ['confirm' => 'true']), + ); + + $result = $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + + self::assertNull($result); + $reloaded = $this->subjectRepository->get($subject->id); + self::assertNotNull($reloaded->deletedAt); + } + + #[Test] + public function itRejectsUnauthorizedAccess(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(granted: false); + + $this->expectException(AccessDeniedHttpException::class); + + $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + } + + #[Test] + public function itRejectsWhenTenantNotSet(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(tenantContext: new TenantContext()); + + $this->expectException(UnauthorizedHttpException::class); + + $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + } + + #[Test] + public function itReturnsNotFoundWhenIdMissing(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(); + + $this->expectException(NotFoundHttpException::class); + + $processor->process(SubjectResource::fromDomain($subject), new Delete(), []); + } + + private function persistSubject(): Subject + { + $subject = Subject::creer( + tenantId: DomainTenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + name: new SubjectName('Mathématiques'), + code: new SubjectCode('MATH'), + color: null, + createdAt: $this->clock->now(), + ); + + $this->subjectRepository->save($subject); + + return $subject; + } + + private function statsReader(int $evaluations, int $grades): SubjectGradeStatsReader + { + return new class($evaluations, $grades) implements SubjectGradeStatsReader { + public function __construct( + private int $evaluations, + private int $grades, + ) { + } + + #[Override] + public function countForSubject( + DomainTenantId $tenantId, + SubjectId $subjectId, + ): SubjectGradeStats { + return new SubjectGradeStats($this->evaluations, $this->grades); + } + }; + } + + private function createProcessor( + bool $granted = true, + ?TenantContext $tenantContext = null, + ?SubjectGradeStatsReader $statsReader = null, + ?Request $request = null, + ): DeleteSubjectProcessor { + $archiveHandler = new ArchiveSubjectHandler($this->subjectRepository, $this->clock); + $gradeStatsHandler = new GetSubjectGradeStatsHandler( + $statsReader ?? $this->statsReader(0, 0), + ); + + $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(SubjectVoter::DELETE) + ->willReturn($granted); + + $requestStack = new RequestStack(); + if ($request !== null) { + $requestStack->push($request); + } + + return new DeleteSubjectProcessor( + $archiveHandler, + $gradeStatsHandler, + $tenantContext ?? $this->tenantContext, + $eventBus, + $authorizationChecker, + $requestStack, + ); + } +} diff --git a/frontend/e2e/subjects.spec.ts b/frontend/e2e/subjects.spec.ts index b68ee44..9fbc81c 100644 --- a/frontend/e2e/subjects.spec.ts +++ b/frontend/e2e/subjects.spec.ts @@ -456,21 +456,95 @@ test.describe('Subjects Management (Story 2.2)', () => { // AC3: Deletion with warning for subjects with grades // ============================================================================ test.describe('AC3: Deletion with warning for grades', () => { - // SKIP REASON: The Grades module is not yet implemented. - // HasGradesForSubjectHandler currently returns false (stub), so all subjects - // appear without grades and can be deleted without warning. This test will - // be enabled once the Grades module allows recording grades for subjects. - // - // When enabled, this test should: - // 1. Create a subject - // 2. Add at least one grade to it - // 3. Attempt to delete the subject - // 4. Verify the warning message about grades - // 5. Require explicit confirmation - test.skip('shows warning when trying to delete subject with grades', async ({ page }) => { + // Subjects seeded by tests in this describe — nettoyés en afterAll via + // l'endpoint DELETE /test/seed/subject-with-grades/{subjectId} qui purge + // les évaluations et notes associées (le subject lui-même étant soft-deleté + // par le flow normal via la modale). + const seededSubjectIds: string[] = []; + + // Helper to extract UUIDs from `dbal:run-sql` output — garde pour le + // `subjectId` créé par l'UI (une seule requête par test vs. 4 auparavant). + function firstUuidFromSql(sql: string): string | null { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); + const match = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); + return match ? match[0] : null; + } + + test.afterAll(async ({ request }) => { + for (const subjectId of seededSubjectIds) { + try { + await request.delete(`${ALPHA_URL}/test/seed/subject-with-grades/${subjectId}`); + } catch { + // Best-effort : l'absence de cleanup ne doit pas faire échouer la suite. + } + } + seededSubjectIds.length = 0; + }); + + test('shows impact warning with evaluation and grade counts before deletion', async ({ + page + }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/subjects`); - // Implementation pending Grades module + + // Create a subject for which we will seed evaluations/grades + await openNewSubjectDialog(page); + const subjectName = `WithGrades-${Date.now()}`; + const subjectCode = `WG${Date.now() % 10000}`; + await page.locator('#subject-name').fill(subjectName); + await page.locator('#subject-code').fill(subjectCode); + await page.getByRole('button', { name: /créer la matière/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + const subjectId = firstUuidFromSql( + `SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' AND code = '${subjectCode.toUpperCase()}' LIMIT 1` + ); + if (!subjectId) { + throw new Error('Failed to resolve subjectId'); + } + seededSubjectIds.push(subjectId); + + // Seed classe + 2 évaluations + 2 notes en UN appel HTTP au lieu de 6+ + // `docker exec dbal:run-sql`. Gain : ~30-60 s → ~5-10 s par test. + const seedResponse = await page.request.post( + `${ALPHA_URL}/test/seed/subject-with-grades`, + { + data: { + subjectId, + teacherEmail: ADMIN_EMAIL, + evaluationCount: 2, + gradesPerEval: 1 + } + } + ); + if (!seedResponse.ok()) { + throw new Error( + `Seed endpoint failed: ${seedResponse.status()} ${await seedResponse.text()}` + ); + } + + clearCache(); + await page.reload(); + + const subjectCard = page.locator('.subject-card', { hasText: subjectName }); + await subjectCard.getByRole('button', { name: /supprimer/i }).click(); + + const deleteModal = page.getByRole('alertdialog'); + await expect(deleteModal).toBeVisible({ timeout: 10000 }); + + const impact = deleteModal.getByTestId('delete-subject-impact'); + await expect(impact).toBeVisible(); + + // AC1 exact wording: "X évaluations et Y notes seront affectées" + const summary = deleteModal.getByTestId('delete-subject-impact-summary'); + await expect(summary).toHaveText(/2 évaluations et 2 notes seront affectées\./); + + await deleteModal.getByRole('button', { name: /supprimer/i }).click(); + await expect(deleteModal).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(subjectName)).not.toBeVisible({ timeout: 10000 }); }); }); diff --git a/frontend/src/lib/features/subjects/api/deleteSubject.ts b/frontend/src/lib/features/subjects/api/deleteSubject.ts new file mode 100644 index 0000000..89468bc --- /dev/null +++ b/frontend/src/lib/features/subjects/api/deleteSubject.ts @@ -0,0 +1,72 @@ +/** + * Logique de suppression d'une matière, extraite du composant Svelte + * pour être testable unitairement. + * + * Flux : + * - Si la liste indique `hasGrades === true`, on envoie `?confirm=true` pour forcer + * le backend à accepter la suppression (confirmation déjà donnée par l'admin). + * - Si `hasGrades` est `null` (stats non chargées) ou `false`, on envoie un DELETE simple : + * le backend décidera. En cas de stats obsolètes côté UI, il renverra 409. + * - 409 = les stats côté liste étaient périmées ; on renvoie `status: 'conflict'` pour + * que l'appelant rafraîchisse la liste et affiche un message explicatif plutôt qu'une + * erreur générique. + */ + +export interface DeleteSubjectInput { + id: string; + hasGrades: boolean | null; +} + +export type DeleteSubjectResult = + | { status: 'success' } + | { status: 'conflict'; message: string } + | { status: 'error'; message: string }; + +export type DeleteSubjectFetch = (url: string, init?: RequestInit) => Promise; + +export async function deleteSubject( + subject: DeleteSubjectInput, + fetchFn: DeleteSubjectFetch, + apiBaseUrl: string +): Promise { + const url = + subject.hasGrades === true + ? `${apiBaseUrl}/subjects/${subject.id}?confirm=true` + : `${apiBaseUrl}/subjects/${subject.id}`; + + const response = await fetchFn(url, { method: 'DELETE' }); + + if (response.ok) { + return { status: 'success' }; + } + + const message = await extractErrorMessage(response); + + if (response.status === 409) { + return { status: 'conflict', message }; + } + + return { status: 'error', message }; +} + +async function extractErrorMessage(response: Response): Promise { + const fallback = `Erreur lors de la suppression (${response.status})`; + try { + const errorData = await response.json(); + if (typeof errorData === 'object' && errorData !== null) { + const record = errorData as Record; + if (typeof record['hydra:description'] === 'string') { + return record['hydra:description']; + } + if (typeof record['message'] === 'string') { + return record['message']; + } + if (typeof record['detail'] === 'string') { + return record['detail']; + } + } + } catch { + // JSON parsing failed, keep fallback + } + return fallback; +} diff --git a/frontend/src/routes/admin/subjects/+page.svelte b/frontend/src/routes/admin/subjects/+page.svelte index d17e208..98bc519 100644 --- a/frontend/src/routes/admin/subjects/+page.svelte +++ b/frontend/src/routes/admin/subjects/+page.svelte @@ -5,6 +5,7 @@ import { authenticatedFetch } from '$lib/auth'; import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte'; import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte'; + import { deleteSubject } from '$lib/features/subjects/api/deleteSubject'; import { untrack } from 'svelte'; // Types @@ -17,6 +18,9 @@ status: string; teacherCount: number | null; classCount: number | null; + evaluationCount: number | null; + gradeCount: number | null; + hasGrades: boolean | null; createdAt: string; updatedAt: string; } @@ -177,29 +181,26 @@ async function handleConfirmDelete() { if (!subjectToDelete) return; + if (isDeleting) return; try { isDeleting = true; - const apiUrl = getApiBaseUrl(); - const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectToDelete.id}`, { - method: 'DELETE' - }); + const result = await deleteSubject( + { id: subjectToDelete.id, hasGrades: subjectToDelete.hasGrades }, + authenticatedFetch, + getApiBaseUrl() + ); - if (!response.ok) { - let errorMessage = `Erreur lors de la suppression (${response.status})`; - try { - const errorData = await response.json(); - if (errorData['hydra:description']) { - errorMessage = errorData['hydra:description']; - } else if (errorData.message) { - errorMessage = errorData.message; - } else if (errorData.detail) { - errorMessage = errorData.detail; - } - } catch { - // JSON parsing failed, keep default message - } - throw new Error(errorMessage); + if (result.status === 'conflict') { + // Stats côté UI périmées : rafraîchir la liste pour que l'admin voie l'impact réel. + closeDeleteModal(); + await loadSubjects(); + error = `${result.message} La liste a été rafraîchie, réessayez pour voir l'impact exact.`; + return; + } + + if (result.status === 'error') { + throw new Error(result.message); } closeDeleteModal(); @@ -313,6 +314,10 @@ 🏫 {subject.classCount ?? 0} + + 📝 + {subject.evaluationCount ?? 0} + {subject.status === 'active' ? 'Active' : 'Archivée'} @@ -448,7 +453,9 @@ role="alertdialog" aria-modal="true" aria-labelledby="delete-modal-title" - aria-describedby="delete-modal-description" + aria-describedby={subjectToDelete.hasGrades + ? 'delete-modal-description delete-subject-impact' + : 'delete-modal-description'} tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }} @@ -463,6 +470,23 @@ Êtes-vous sûr de vouloir supprimer la matière {subjectToDelete.name} ({subjectToDelete.code}) ?

+ {#if subjectToDelete.hasGrades} +
+

+ ⚠️ {subjectToDelete.evaluationCount ?? 0} + évaluation{(subjectToDelete.evaluationCount ?? 0) > 1 ? 's' : ''} et + {subjectToDelete.gradeCount ?? 0} + note{(subjectToDelete.gradeCount ?? 0) > 1 ? 's' : ''} seront affectées. +

+

+ Ces données resteront consultables dans l'historique mais la matière ne sera plus sélectionnable. +

+
+ {/if}

Cette action est irréversible.

@@ -906,4 +930,26 @@ font-size: 0.875rem; color: #6b7280; } + + .delete-impact { + margin: 1rem 0 0; + padding: 0.875rem 1rem; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 0.375rem; + } + + .delete-impact-title { + margin: 0; + font-weight: 600; + color: #92400e; + font-size: 0.875rem; + } + + .delete-impact-note { + margin: 0.5rem 0 0; + font-size: 0.8125rem; + color: #78350f; + font-style: italic; + } diff --git a/frontend/tests/unit/lib/features/subjects/api/deleteSubject.test.ts b/frontend/tests/unit/lib/features/subjects/api/deleteSubject.test.ts new file mode 100644 index 0000000..1e87fbe --- /dev/null +++ b/frontend/tests/unit/lib/features/subjects/api/deleteSubject.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from 'vitest'; +import { deleteSubject } from '$lib/features/subjects/api/deleteSubject'; + +const API = 'http://test.classeo.local:18000/api'; + +function makeResponse(status: number, body?: Record): Response { + const init: ResponseInit = { status }; + if (body !== undefined) { + init.headers = { 'Content-Type': 'application/json' }; + } + return new Response(body !== undefined ? JSON.stringify(body) : null, init); +} + +describe('deleteSubject', () => { + it('envoie DELETE sans confirm quand hasGrades=false', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204)); + + const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API); + + expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc`, { method: 'DELETE' }); + expect(result).toEqual({ status: 'success' }); + }); + + it('envoie DELETE sans confirm quand hasGrades=null (pour laisser le backend décider)', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204)); + + await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API); + + expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc`, { method: 'DELETE' }); + }); + + it('ajoute ?confirm=true quand hasGrades=true', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204)); + + await deleteSubject({ id: 'abc', hasGrades: true }, fetchFn, API); + + expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc?confirm=true`, { method: 'DELETE' }); + }); + + it('retourne un status conflict avec le message backend sur 409', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce( + makeResponse(409, { + 'hydra:description': + 'Cette matière est liée à 3 évaluation(s) et 12 note(s). Confirmez la suppression pour continuer.' + }) + ); + + const result = await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API); + + expect(result).toEqual({ + status: 'conflict', + message: + 'Cette matière est liée à 3 évaluation(s) et 12 note(s). Confirmez la suppression pour continuer.' + }); + }); + + it('retourne un status error sur autre code HTTP', async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(makeResponse(500, { detail: 'Internal server error' })); + + const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API); + + expect(result).toEqual({ status: 'error', message: 'Internal server error' }); + }); + + it('fallback message si pas de payload JSON', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(403)); + + const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API); + + expect(result).toEqual({ status: 'error', message: 'Erreur lors de la suppression (403)' }); + }); + + it('extrait message depuis le champ `message`', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(409, { message: 'Conflit' })); + + const result = await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API); + + expect(result).toEqual({ status: 'conflict', message: 'Conflit' }); + }); +});