From 80ce289b8694f6188acfbf4c8e2885f17faa3e50 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 22 Apr 2026 21:10:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Tracer=20automatiquement=20les=20=C3=A9?= =?UTF-8?q?v=C3=A9nements=20notes=20et=20=C3=A9valuations=20dans=20l'audit?= =?UTF-8?q?=20trail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 1-7 avait posé les fondations d'audit trail mais laissé en dehors du périmètre initial les événements notes/évaluations, qui étaient alors non couverts par les domaines. Avec la clôture des epics notation, ces actions sensibles (création/modification/suppression d'évaluation, saisie/modification de note, publication) doivent maintenant être tracées pour répondre aux exigences RGPD et faciliter la résolution des litiges parent/enseignant. Les événements de domaine existants ne transportaient pas tous les champs nécessaires à l'audit (ancien/nouveau titre, description, barème, coefficient, date, studentId). L'enrichissement de leur payload permet aux handlers d'audit de journaliser les diffs complets via AuditLogger, sans que les autres consommateurs (recalcul de moyennes) n'aient besoin de changer leur logique. Au passage, le test E2E student-grades AC5 ("Nouveau" badge) visait séquentiellement '.grade-card' puis '.badge-new' : la fenêtre de 3 s avant markGradesSeen pouvait se refermer entre les deux attentes sur Firefox CI. Un seul expect combiné '.grade-card .badge-new' élimine cette course. --- .../sprint-status.yaml | 6 +- .../Domain/Event/EvaluationCreee.php | 3 + .../Domain/Event/EvaluationModifiee.php | 12 +- .../Scolarite/Domain/Event/NoteModifiee.php | 1 + .../Domain/Model/Evaluation/Evaluation.php | 21 +- .../Scolarite/Domain/Model/Grade/Grade.php | 1 + .../AuditEvaluationEventsHandler.php | 89 ++++++++ .../EventHandler/AuditGradeEventsHandler.php | 57 +++++ ...tEvaluationEventsHandlerFunctionalTest.php | 204 ++++++++++++++++++ .../AuditGradeEventsHandlerFunctionalTest.php | 142 ++++++++++++ .../Model/Evaluation/EvaluationTest.php | 14 ++ .../Domain/Model/Grade/GradeTest.php | 1 + .../AuditEvaluationEventsHandlerTest.php | 157 ++++++++++++++ .../AuditGradeEventsHandlerTest.php | 188 ++++++++++++++++ ...oyennesOnEvaluationModifieeHandlerTest.php | 60 +++++- ...culerMoyennesOnNoteModifieeHandlerTest.php | 4 + frontend/e2e/student-grades.spec.ts | 10 +- 17 files changed, 949 insertions(+), 21 deletions(-) create mode 100644 backend/src/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandler.php create mode 100644 backend/src/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandler.php create mode 100644 backend/tests/Functional/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandlerFunctionalTest.php create mode 100644 backend/tests/Functional/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandlerFunctionalTest.php create mode 100644 backend/tests/Unit/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandlerTest.php diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index df95f54..b9ab110 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -38,7 +38,7 @@ project: classeo project_key: classeo tracking_system: file-system story_location: _bmad-output/implementation-artifacts -last_updated: 2026-04-17 +last_updated: 2026-04-22 development_status: # Epic 1: Fondations, Auth & Observabilité (9 stories) @@ -123,8 +123,8 @@ development_status: 6-7-consultation-notes-par-le-parent: done 6-8-statistiques-enseignant: done 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-10-statistiques-notes-par-matiere-admin: done # Débloque tâches différées de 2-2 + 6-11-audit-trail-evenements-notes: done # 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) epic-6-retrospective: optional diff --git a/backend/src/Scolarite/Domain/Event/EvaluationCreee.php b/backend/src/Scolarite/Domain/Event/EvaluationCreee.php index 2dc03f5..c04a61b 100644 --- a/backend/src/Scolarite/Domain/Event/EvaluationCreee.php +++ b/backend/src/Scolarite/Domain/Event/EvaluationCreee.php @@ -18,7 +18,10 @@ final readonly class EvaluationCreee implements DomainEvent public string $subjectId, public string $teacherId, public string $title, + public ?string $description, public DateTimeImmutable $evaluationDate, + public int $gradeScale, + public float $coefficient, private DateTimeImmutable $occurredOn, ) { } diff --git a/backend/src/Scolarite/Domain/Event/EvaluationModifiee.php b/backend/src/Scolarite/Domain/Event/EvaluationModifiee.php index 0aaaa00..6f95687 100644 --- a/backend/src/Scolarite/Domain/Event/EvaluationModifiee.php +++ b/backend/src/Scolarite/Domain/Event/EvaluationModifiee.php @@ -14,8 +14,16 @@ final readonly class EvaluationModifiee implements DomainEvent { public function __construct( public EvaluationId $evaluationId, - public string $title, - public DateTimeImmutable $evaluationDate, + public string $oldTitle, + public string $newTitle, + public ?string $oldDescription, + public ?string $newDescription, + public float $oldCoefficient, + public float $newCoefficient, + public DateTimeImmutable $oldEvaluationDate, + public DateTimeImmutable $newEvaluationDate, + public int $oldGradeScale, + public int $newGradeScale, private DateTimeImmutable $occurredOn, ) { } diff --git a/backend/src/Scolarite/Domain/Event/NoteModifiee.php b/backend/src/Scolarite/Domain/Event/NoteModifiee.php index 6301d70..6f4b685 100644 --- a/backend/src/Scolarite/Domain/Event/NoteModifiee.php +++ b/backend/src/Scolarite/Domain/Event/NoteModifiee.php @@ -15,6 +15,7 @@ final readonly class NoteModifiee implements DomainEvent public function __construct( public GradeId $gradeId, public string $evaluationId, + public string $studentId, public ?float $oldValue, public ?float $newValue, public string $oldStatus, diff --git a/backend/src/Scolarite/Domain/Model/Evaluation/Evaluation.php b/backend/src/Scolarite/Domain/Model/Evaluation/Evaluation.php index 5796454..90bf0ff 100644 --- a/backend/src/Scolarite/Domain/Model/Evaluation/Evaluation.php +++ b/backend/src/Scolarite/Domain/Model/Evaluation/Evaluation.php @@ -73,7 +73,10 @@ final class Evaluation extends AggregateRoot subjectId: (string) $subjectId, teacherId: (string) $teacherId, title: $title, + description: $description, evaluationDate: $evaluationDate, + gradeScale: $gradeScale->maxValue, + coefficient: $coefficient->value, occurredOn: $now, )); @@ -97,6 +100,12 @@ final class Evaluation extends AggregateRoot throw BaremeNonModifiableException::carNotesExistantes($this->id); } + $oldTitle = $this->title; + $oldDescription = $this->description; + $oldCoefficient = $this->coefficient; + $oldEvaluationDate = $this->evaluationDate; + $oldGradeScale = $this->gradeScale; + $this->title = $title; $this->description = $description; $this->coefficient = $coefficient; @@ -110,8 +119,16 @@ final class Evaluation extends AggregateRoot $this->recordEvent(new EvaluationModifiee( evaluationId: $this->id, - title: $title, - evaluationDate: $evaluationDate, + oldTitle: $oldTitle, + newTitle: $this->title, + oldDescription: $oldDescription, + newDescription: $this->description, + oldCoefficient: $oldCoefficient->value, + newCoefficient: $this->coefficient->value, + oldEvaluationDate: $oldEvaluationDate, + newEvaluationDate: $this->evaluationDate, + oldGradeScale: $oldGradeScale->maxValue, + newGradeScale: $this->gradeScale->maxValue, occurredOn: $now, )); } diff --git a/backend/src/Scolarite/Domain/Model/Grade/Grade.php b/backend/src/Scolarite/Domain/Model/Grade/Grade.php index d76af73..63cafe1 100644 --- a/backend/src/Scolarite/Domain/Model/Grade/Grade.php +++ b/backend/src/Scolarite/Domain/Model/Grade/Grade.php @@ -96,6 +96,7 @@ final class Grade extends AggregateRoot $this->recordEvent(new NoteModifiee( gradeId: $this->id, evaluationId: (string) $this->evaluationId, + studentId: (string) $this->studentId, oldValue: $oldValue, newValue: $value?->value, oldStatus: $oldStatus, diff --git a/backend/src/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandler.php b/backend/src/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandler.php new file mode 100644 index 0000000..aa2cf70 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandler.php @@ -0,0 +1,89 @@ +auditLogger->logDataChange( + aggregateType: 'Evaluation', + aggregateId: $event->evaluationId->value, + eventType: 'EvaluationCreee', + oldValues: [], + newValues: [ + 'title' => $event->title, + 'description' => $event->description, + 'class_id' => $event->classId, + 'subject_id' => $event->subjectId, + 'teacher_id' => $event->teacherId, + 'evaluation_date' => $event->evaluationDate->format('Y-m-d'), + 'grade_scale' => $event->gradeScale, + 'coefficient' => $event->coefficient, + ], + ); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function handleEvaluationModifiee(EvaluationModifiee $event): void + { + $this->auditLogger->logDataChange( + aggregateType: 'Evaluation', + aggregateId: $event->evaluationId->value, + eventType: 'EvaluationModifiee', + oldValues: [ + 'title' => $event->oldTitle, + 'description' => $event->oldDescription, + 'coefficient' => $event->oldCoefficient, + 'evaluation_date' => $event->oldEvaluationDate->format('Y-m-d'), + 'grade_scale' => $event->oldGradeScale, + ], + newValues: [ + 'title' => $event->newTitle, + 'description' => $event->newDescription, + 'coefficient' => $event->newCoefficient, + 'evaluation_date' => $event->newEvaluationDate->format('Y-m-d'), + 'grade_scale' => $event->newGradeScale, + ], + ); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function handleEvaluationSupprimee(EvaluationSupprimee $event): void + { + $this->auditLogger->logDataChange( + aggregateType: 'Evaluation', + aggregateId: $event->evaluationId->value, + eventType: 'EvaluationSupprimee', + oldValues: [], + newValues: [], + ); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function handleNotesPubliees(NotesPubliees $event): void + { + $this->auditLogger->logDataChange( + aggregateType: 'Evaluation', + aggregateId: $event->evaluationId->value, + eventType: 'NotesPubliees', + oldValues: [], + newValues: [], + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandler.php b/backend/src/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandler.php new file mode 100644 index 0000000..ad3a328 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandler.php @@ -0,0 +1,57 @@ +auditLogger->logDataChange( + aggregateType: 'Grade', + aggregateId: $event->gradeId->value, + eventType: 'NoteSaisie', + oldValues: [], + newValues: [ + 'evaluation_id' => $event->evaluationId, + 'student_id' => $event->studentId, + 'value' => $event->value, + 'status' => $event->status, + 'created_by' => $event->createdBy, + ], + ); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function handleNoteModifiee(NoteModifiee $event): void + { + $this->auditLogger->logDataChange( + aggregateType: 'Grade', + aggregateId: $event->gradeId->value, + eventType: 'NoteModifiee', + oldValues: [ + 'value' => $event->oldValue, + 'status' => $event->oldStatus, + ], + newValues: [ + 'evaluation_id' => $event->evaluationId, + 'student_id' => $event->studentId, + 'value' => $event->newValue, + 'status' => $event->newStatus, + 'modified_by' => $event->modifiedBy, + ], + ); + } +} diff --git a/backend/tests/Functional/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandlerFunctionalTest.php b/backend/tests/Functional/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandlerFunctionalTest.php new file mode 100644 index 0000000..4b45a57 --- /dev/null +++ b/backend/tests/Functional/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandlerFunctionalTest.php @@ -0,0 +1,204 @@ +get(Connection::class); + $this->connection = $connection; + + /** @var AuditEvaluationEventsHandler $handler */ + $handler = static::getContainer()->get(AuditEvaluationEventsHandler::class); + $this->handler = $handler; + } + + #[Test] + public function handleEvaluationCreeeWritesAuditEntryToDatabase(): void + { + $evaluationId = EvaluationId::generate(); + $classId = Uuid::uuid4()->toString(); + $subjectId = Uuid::uuid4()->toString(); + $teacherId = Uuid::uuid4()->toString(); + + $event = new EvaluationCreee( + evaluationId: $evaluationId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: 'Contrôle chapitre 3', + description: 'Sur les parties 1 et 2', + evaluationDate: new DateTimeImmutable('2026-04-15'), + gradeScale: 20, + coefficient: 2.0, + occurredOn: new DateTimeImmutable(), + ); + + $this->handler->handleEvaluationCreee($event); + + $entry = $this->connection->fetchAssociative( + 'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1', + [$evaluationId->value->toString(), 'EvaluationCreee'], + ); + + self::assertNotFalse($entry, 'Audit log entry should exist after EvaluationCreee'); + self::assertSame('Evaluation', $entry['aggregate_type']); + + $payload = self::decodePayload($entry['payload']); + self::assertSame([], $payload['old_values']); + self::assertSame('Contrôle chapitre 3', $payload['new_values']['title']); + self::assertSame('Sur les parties 1 et 2', $payload['new_values']['description']); + self::assertSame($classId, $payload['new_values']['class_id']); + self::assertSame($subjectId, $payload['new_values']['subject_id']); + self::assertSame($teacherId, $payload['new_values']['teacher_id']); + self::assertSame('2026-04-15', $payload['new_values']['evaluation_date']); + self::assertSame(20, $payload['new_values']['grade_scale']); + self::assertSame(2.0, $payload['new_values']['coefficient']); + } + + #[Test] + public function handleEvaluationModifieeWritesAuditEntryWithDiff(): void + { + $evaluationId = EvaluationId::generate(); + + $event = new EvaluationModifiee( + evaluationId: $evaluationId, + oldTitle: 'Ancien titre', + newTitle: 'Nouveau titre', + oldDescription: null, + newDescription: 'Description ajoutée', + oldCoefficient: 1.0, + newCoefficient: 3.0, + oldEvaluationDate: new DateTimeImmutable('2026-04-15'), + newEvaluationDate: new DateTimeImmutable('2026-05-02'), + oldGradeScale: 20, + newGradeScale: 100, + occurredOn: new DateTimeImmutable(), + ); + + $this->handler->handleEvaluationModifiee($event); + + $entry = $this->connection->fetchAssociative( + 'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1', + [$evaluationId->value->toString(), 'EvaluationModifiee'], + ); + + self::assertNotFalse($entry, 'Audit log entry should exist after EvaluationModifiee'); + self::assertSame('Evaluation', $entry['aggregate_type']); + + $payload = self::decodePayload($entry['payload']); + self::assertSame('Ancien titre', $payload['old_values']['title']); + self::assertNull($payload['old_values']['description']); + self::assertSame(1.0, $payload['old_values']['coefficient']); + self::assertSame('2026-04-15', $payload['old_values']['evaluation_date']); + self::assertSame(20, $payload['old_values']['grade_scale']); + + self::assertSame('Nouveau titre', $payload['new_values']['title']); + self::assertSame('Description ajoutée', $payload['new_values']['description']); + self::assertSame(3.0, $payload['new_values']['coefficient']); + self::assertSame('2026-05-02', $payload['new_values']['evaluation_date']); + self::assertSame(100, $payload['new_values']['grade_scale']); + } + + #[Test] + public function handleNotesPublieesWritesAuditEntryToDatabase(): void + { + $evaluationId = EvaluationId::generate(); + + $event = new NotesPubliees( + evaluationId: $evaluationId, + occurredOn: new DateTimeImmutable(), + ); + + $this->handler->handleNotesPubliees($event); + + $entry = $this->connection->fetchAssociative( + 'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1', + [$evaluationId->value->toString(), 'NotesPubliees'], + ); + + self::assertNotFalse($entry, 'Audit log entry should exist after NotesPubliees'); + self::assertSame('Evaluation', $entry['aggregate_type']); + self::assertSame($evaluationId->value->toString(), $entry['aggregate_id']); + + $payload = self::decodePayload($entry['payload']); + self::assertSame([], $payload['old_values']); + self::assertSame([], $payload['new_values']); + + $metadata = self::decodePayload($entry['metadata']); + self::assertArrayHasKey('correlation_id', $metadata); + self::assertArrayHasKey('occurred_at', $metadata); + } + + #[Test] + public function handleEvaluationSupprimeeWritesAuditEntryToDatabase(): void + { + $evaluationId = EvaluationId::generate(); + + $event = new EvaluationSupprimee( + evaluationId: $evaluationId, + occurredOn: new DateTimeImmutable(), + ); + + $this->handler->handleEvaluationSupprimee($event); + + $entry = $this->connection->fetchAssociative( + 'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1', + [$evaluationId->value->toString(), 'EvaluationSupprimee'], + ); + + self::assertNotFalse($entry, 'Audit log entry should exist after EvaluationSupprimee'); + self::assertSame('Evaluation', $entry['aggregate_type']); + self::assertSame($evaluationId->value->toString(), $entry['aggregate_id']); + + $payload = self::decodePayload($entry['payload']); + self::assertSame([], $payload['old_values']); + self::assertSame([], $payload['new_values']); + + $metadata = self::decodePayload($entry['metadata']); + self::assertArrayHasKey('correlation_id', $metadata); + self::assertArrayHasKey('occurred_at', $metadata); + } + + /** + * @return array + */ + private static function decodePayload(mixed $raw): array + { + self::assertIsString($raw); + + /** @var array $decoded */ + $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } +} diff --git a/backend/tests/Functional/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandlerFunctionalTest.php b/backend/tests/Functional/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandlerFunctionalTest.php new file mode 100644 index 0000000..f8474aa --- /dev/null +++ b/backend/tests/Functional/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandlerFunctionalTest.php @@ -0,0 +1,142 @@ +get(Connection::class); + $this->connection = $connection; + + /** @var AuditGradeEventsHandler $handler */ + $handler = static::getContainer()->get(AuditGradeEventsHandler::class); + $this->handler = $handler; + } + + protected function tearDown(): void + { + // audit_log est append-only : pas de DELETE possible, on filtre par UUID unique dans chaque test + parent::tearDown(); + } + + #[Test] + public function handleNoteSaisieWritesAuditEntryToDatabase(): void + { + $gradeId = GradeId::generate(); + $evaluationId = Uuid::uuid4()->toString(); + $studentId = Uuid::uuid4()->toString(); + $createdBy = Uuid::uuid4()->toString(); + + $event = new NoteSaisie( + gradeId: $gradeId, + evaluationId: $evaluationId, + studentId: $studentId, + value: 15.5, + status: 'draft', + createdBy: $createdBy, + occurredOn: new DateTimeImmutable(), + ); + + $this->handler->handleNoteSaisie($event); + + $entry = $this->connection->fetchAssociative( + 'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1', + [$gradeId->value->toString(), 'NoteSaisie'], + ); + + self::assertNotFalse($entry, 'Audit log entry should exist after NoteSaisie'); + self::assertSame('Grade', $entry['aggregate_type']); + + $payload = self::decodePayload($entry['payload']); + self::assertSame([], $payload['old_values']); + self::assertSame($evaluationId, $payload['new_values']['evaluation_id']); + self::assertSame($studentId, $payload['new_values']['student_id']); + self::assertSame(15.5, $payload['new_values']['value']); + self::assertSame('draft', $payload['new_values']['status']); + self::assertSame($createdBy, $payload['new_values']['created_by']); + + self::assertArrayHasKey('metadata', $entry); + $metadata = self::decodePayload($entry['metadata']); + self::assertArrayHasKey('correlation_id', $metadata); + self::assertArrayHasKey('occurred_at', $metadata); + } + + #[Test] + public function handleNoteModifieeWritesAuditEntryWithDiff(): void + { + $gradeId = GradeId::generate(); + $evaluationId = Uuid::uuid4()->toString(); + $studentId = Uuid::uuid4()->toString(); + $modifiedBy = Uuid::uuid4()->toString(); + + $event = new NoteModifiee( + gradeId: $gradeId, + evaluationId: $evaluationId, + studentId: $studentId, + oldValue: 12.0, + newValue: 14.5, + oldStatus: 'draft', + newStatus: 'published', + modifiedBy: $modifiedBy, + occurredOn: new DateTimeImmutable(), + ); + + $this->handler->handleNoteModifiee($event); + + $entry = $this->connection->fetchAssociative( + 'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1', + [$gradeId->value->toString(), 'NoteModifiee'], + ); + + self::assertNotFalse($entry, 'Audit log entry should exist after NoteModifiee'); + self::assertSame('Grade', $entry['aggregate_type']); + + $payload = self::decodePayload($entry['payload']); + self::assertSame(['value' => 12.0, 'status' => 'draft'], $payload['old_values']); + self::assertSame(14.5, $payload['new_values']['value']); + self::assertSame('published', $payload['new_values']['status']); + self::assertSame($modifiedBy, $payload['new_values']['modified_by']); + self::assertSame($evaluationId, $payload['new_values']['evaluation_id']); + self::assertSame($studentId, $payload['new_values']['student_id']); + } + + /** + * @return array + */ + private static function decodePayload(mixed $raw): array + { + self::assertIsString($raw); + + /** @var array $decoded */ + $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/EvaluationTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/EvaluationTest.php index d045ef7..15b935c 100644 --- a/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/EvaluationTest.php +++ b/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/EvaluationTest.php @@ -47,6 +47,10 @@ final class EvaluationTest extends TestCase self::assertCount(1, $events); self::assertInstanceOf(EvaluationCreee::class, $events[0]); self::assertSame($evaluation->id, $events[0]->evaluationId); + self::assertSame($evaluation->title, $events[0]->title); + self::assertSame($evaluation->description, $events[0]->description); + self::assertSame($evaluation->gradeScale->maxValue, $events[0]->gradeScale); + self::assertSame($evaluation->coefficient->value, $events[0]->coefficient); } #[Test] @@ -136,6 +140,16 @@ final class EvaluationTest extends TestCase self::assertCount(1, $events); self::assertInstanceOf(EvaluationModifiee::class, $events[0]); self::assertSame($evaluation->id, $events[0]->evaluationId); + self::assertSame('Contrôle chapitre 5', $events[0]->oldTitle); + self::assertSame('Titre modifié', $events[0]->newTitle); + self::assertSame('Évaluation sur les fonctions', $events[0]->oldDescription); + self::assertSame('Nouvelle description', $events[0]->newDescription); + self::assertSame(1.0, $events[0]->oldCoefficient); + self::assertSame(2.0, $events[0]->newCoefficient); + self::assertEquals(new DateTimeImmutable('2026-04-15'), $events[0]->oldEvaluationDate); + self::assertEquals($newDate, $events[0]->newEvaluationDate); + self::assertSame(20, $events[0]->oldGradeScale); + self::assertSame(20, $events[0]->newGradeScale); } #[Test] diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php index 3a032c8..e9d67a9 100644 --- a/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php +++ b/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php @@ -179,6 +179,7 @@ final class GradeTest extends TestCase $events = $grade->pullDomainEvents(); self::assertCount(1, $events); self::assertInstanceOf(NoteModifiee::class, $events[0]); + self::assertSame(self::STUDENT_ID, $events[0]->studentId); self::assertSame(15.5, $events[0]->oldValue); self::assertSame(18.0, $events[0]->newValue); self::assertSame('graded', $events[0]->oldStatus); diff --git a/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandlerTest.php b/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandlerTest.php new file mode 100644 index 0000000..4134980 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/AuditEvaluationEventsHandlerTest.php @@ -0,0 +1,157 @@ +auditLogger = $this->createMock(AuditLogger::class); + $this->handler = new AuditEvaluationEventsHandler($this->auditLogger); + } + + public function testHandleEvaluationCreeeLogsAuditEntryWithFullPayload(): void + { + $evaluationId = EvaluationId::generate(); + $classId = Uuid::uuid4()->toString(); + $subjectId = Uuid::uuid4()->toString(); + $teacherId = Uuid::uuid4()->toString(); + $evaluationDate = new DateTimeImmutable('2026-04-15'); + + $event = new EvaluationCreee( + evaluationId: $evaluationId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: 'Contrôle chapitre 3', + description: 'Sur le chapitre 3 uniquement', + evaluationDate: $evaluationDate, + gradeScale: 20, + coefficient: 2.0, + occurredOn: new DateTimeImmutable(), + ); + + $this->auditLogger->expects($this->once()) + ->method('logDataChange') + ->with( + $this->equalTo('Evaluation'), + $this->callback(static fn ($uuid) => $uuid->toString() === $evaluationId->value->toString()), + $this->equalTo('EvaluationCreee'), + $this->equalTo([]), + $this->callback(static fn ($new) => $new['title'] === 'Contrôle chapitre 3' + && $new['description'] === 'Sur le chapitre 3 uniquement' + && $new['class_id'] === $classId + && $new['subject_id'] === $subjectId + && $new['teacher_id'] === $teacherId + && $new['evaluation_date'] === '2026-04-15' + && $new['grade_scale'] === 20 + && $new['coefficient'] === 2.0 + ), + ); + + $this->handler->handleEvaluationCreee($event); + } + + public function testHandleEvaluationModifieeLogsAuditEntryWithDiff(): void + { + $evaluationId = EvaluationId::generate(); + + $event = new EvaluationModifiee( + evaluationId: $evaluationId, + oldTitle: 'Ancien titre', + newTitle: 'Nouveau titre', + oldDescription: 'Ancienne description', + newDescription: 'Nouvelle description', + oldCoefficient: 1.0, + newCoefficient: 2.0, + oldEvaluationDate: new DateTimeImmutable('2026-04-15'), + newEvaluationDate: new DateTimeImmutable('2026-05-01'), + oldGradeScale: 20, + newGradeScale: 100, + occurredOn: new DateTimeImmutable(), + ); + + $this->auditLogger->expects($this->once()) + ->method('logDataChange') + ->with( + $this->equalTo('Evaluation'), + $this->callback(static fn ($uuid) => $uuid->toString() === $evaluationId->value->toString()), + $this->equalTo('EvaluationModifiee'), + $this->callback(static fn ($old) => $old['title'] === 'Ancien titre' + && $old['description'] === 'Ancienne description' + && $old['coefficient'] === 1.0 + && $old['evaluation_date'] === '2026-04-15' + && $old['grade_scale'] === 20 + ), + $this->callback(static fn ($new) => $new['title'] === 'Nouveau titre' + && $new['description'] === 'Nouvelle description' + && $new['coefficient'] === 2.0 + && $new['evaluation_date'] === '2026-05-01' + && $new['grade_scale'] === 100 + ), + ); + + $this->handler->handleEvaluationModifiee($event); + } + + public function testHandleEvaluationSupprimeeLogsAuditEntry(): void + { + $evaluationId = EvaluationId::generate(); + + $event = new EvaluationSupprimee( + evaluationId: $evaluationId, + occurredOn: new DateTimeImmutable(), + ); + + $this->auditLogger->expects($this->once()) + ->method('logDataChange') + ->with( + $this->equalTo('Evaluation'), + $this->callback(static fn ($uuid) => $uuid->toString() === $evaluationId->value->toString()), + $this->equalTo('EvaluationSupprimee'), + $this->equalTo([]), + $this->equalTo([]), + ); + + $this->handler->handleEvaluationSupprimee($event); + } + + public function testHandleNotesPublieesLogsAuditEntry(): void + { + $evaluationId = EvaluationId::generate(); + + $event = new NotesPubliees( + evaluationId: $evaluationId, + occurredOn: new DateTimeImmutable(), + ); + + $this->auditLogger->expects($this->once()) + ->method('logDataChange') + ->with( + $this->equalTo('Evaluation'), + $this->callback(static fn ($uuid) => $uuid->toString() === $evaluationId->value->toString()), + $this->equalTo('NotesPubliees'), + $this->equalTo([]), + $this->equalTo([]), + ); + + $this->handler->handleNotesPubliees($event); + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandlerTest.php b/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandlerTest.php new file mode 100644 index 0000000..6b6bdd8 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/AuditGradeEventsHandlerTest.php @@ -0,0 +1,188 @@ +auditLogger = $this->createMock(AuditLogger::class); + $this->handler = new AuditGradeEventsHandler($this->auditLogger); + } + + public function testHandleNoteSaisieLogsAuditEntryWithCreationPayload(): void + { + $gradeId = GradeId::generate(); + $evaluationId = Uuid::uuid4()->toString(); + $studentId = Uuid::uuid4()->toString(); + $createdBy = Uuid::uuid4()->toString(); + + $event = new NoteSaisie( + gradeId: $gradeId, + evaluationId: $evaluationId, + studentId: $studentId, + value: 15.5, + status: 'draft', + createdBy: $createdBy, + occurredOn: new DateTimeImmutable(), + ); + + $this->auditLogger->expects($this->once()) + ->method('logDataChange') + ->with( + $this->equalTo('Grade'), + $this->callback(static fn ($uuid) => $uuid->toString() === $gradeId->value->toString()), + $this->equalTo('NoteSaisie'), + $this->equalTo([]), + $this->callback(static fn ($new) => $new['evaluation_id'] === $evaluationId + && $new['student_id'] === $studentId + && $new['value'] === 15.5 + && $new['status'] === 'draft' + && $new['created_by'] === $createdBy + ), + ); + + $this->handler->handleNoteSaisie($event); + } + + public function testHandleNoteSaisieSupportsNullValue(): void + { + $event = new NoteSaisie( + gradeId: GradeId::generate(), + evaluationId: Uuid::uuid4()->toString(), + studentId: Uuid::uuid4()->toString(), + value: null, + status: 'absent', + createdBy: Uuid::uuid4()->toString(), + occurredOn: new DateTimeImmutable(), + ); + + $this->auditLogger->expects($this->once()) + ->method('logDataChange') + ->with( + $this->equalTo('Grade'), + $this->anything(), + $this->equalTo('NoteSaisie'), + $this->equalTo([]), + $this->callback(static fn ($new) => $new['value'] === null + && $new['status'] === 'absent' + ), + ); + + $this->handler->handleNoteSaisie($event); + } + + public function testHandleNoteModifieeLogsAuditEntryWithDiff(): void + { + $gradeId = GradeId::generate(); + $evaluationId = Uuid::uuid4()->toString(); + $studentId = Uuid::uuid4()->toString(); + $modifiedBy = Uuid::uuid4()->toString(); + + $event = new NoteModifiee( + gradeId: $gradeId, + evaluationId: $evaluationId, + studentId: $studentId, + oldValue: 12.0, + newValue: 14.5, + oldStatus: 'draft', + newStatus: 'published', + modifiedBy: $modifiedBy, + occurredOn: new DateTimeImmutable(), + ); + + $this->auditLogger->expects($this->once()) + ->method('logDataChange') + ->with( + $this->equalTo('Grade'), + $this->callback(static fn ($uuid) => $uuid->toString() === $gradeId->value->toString()), + $this->equalTo('NoteModifiee'), + $this->callback(static fn ($old) => $old['value'] === 12.0 + && $old['status'] === 'draft' + ), + $this->callback(static fn ($new) => $new['value'] === 14.5 + && $new['status'] === 'published' + && $new['modified_by'] === $modifiedBy + && $new['evaluation_id'] === $evaluationId + && $new['student_id'] === $studentId + ), + ); + + $this->handler->handleNoteModifiee($event); + } + + public function testHandleNoteModifieeSupportsNullValues(): void + { + $event = new NoteModifiee( + gradeId: GradeId::generate(), + evaluationId: Uuid::uuid4()->toString(), + studentId: Uuid::uuid4()->toString(), + oldValue: 10.0, + newValue: null, + oldStatus: 'published', + newStatus: 'absent', + modifiedBy: Uuid::uuid4()->toString(), + occurredOn: new DateTimeImmutable(), + ); + + $this->auditLogger->expects($this->once()) + ->method('logDataChange') + ->with( + $this->equalTo('Grade'), + $this->anything(), + $this->equalTo('NoteModifiee'), + $this->callback(static fn ($old) => $old['value'] === 10.0), + $this->callback(static fn ($new) => $new['value'] === null + && $new['status'] === 'absent' + ), + ); + + $this->handler->handleNoteModifiee($event); + } + + public function testHandleNoteSaisieSupportsZeroValue(): void + { + $event = new NoteSaisie( + gradeId: GradeId::generate(), + evaluationId: Uuid::uuid4()->toString(), + studentId: Uuid::uuid4()->toString(), + value: 0.0, + status: 'published', + createdBy: Uuid::uuid4()->toString(), + occurredOn: new DateTimeImmutable(), + ); + + $this->auditLogger->expects($this->once()) + ->method('logDataChange') + ->with( + $this->equalTo('Grade'), + $this->anything(), + $this->equalTo('NoteSaisie'), + $this->equalTo([]), + $this->callback(static fn ($new) => array_key_exists('value', $new) + && $new['value'] === 0.0 + && $new['status'] === 'published' + ), + ); + + $this->handler->handleNoteSaisie($event); + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnEvaluationModifieeHandlerTest.php b/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnEvaluationModifieeHandlerTest.php index 8820376..916c663 100644 --- a/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnEvaluationModifieeHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnEvaluationModifieeHandlerTest.php @@ -100,8 +100,16 @@ final class RecalculerMoyennesOnEvaluationModifieeHandlerTest extends TestCase ($this->handler)(new EvaluationModifiee( evaluationId: $evaluationId, - title: 'Titre modifié', - evaluationDate: new DateTimeImmutable('2026-02-15'), + oldTitle: 'Test Evaluation', + newTitle: 'Titre modifié', + oldDescription: null, + newDescription: null, + oldCoefficient: 1.0, + newCoefficient: 1.0, + oldEvaluationDate: new DateTimeImmutable('2026-02-15'), + newEvaluationDate: new DateTimeImmutable('2026-02-15'), + oldGradeScale: 20, + newGradeScale: 20, occurredOn: new DateTimeImmutable(), )); @@ -126,8 +134,16 @@ final class RecalculerMoyennesOnEvaluationModifieeHandlerTest extends TestCase ($this->handler)(new EvaluationModifiee( evaluationId: $evaluationId, - title: 'Titre modifié', - evaluationDate: new DateTimeImmutable('2026-02-15'), + oldTitle: 'Test Evaluation', + newTitle: 'Titre modifié', + oldDescription: null, + newDescription: null, + oldCoefficient: 1.0, + newCoefficient: 1.0, + oldEvaluationDate: new DateTimeImmutable('2026-02-15'), + newEvaluationDate: new DateTimeImmutable('2026-02-15'), + oldGradeScale: 20, + newGradeScale: 20, occurredOn: new DateTimeImmutable(), )); @@ -163,8 +179,16 @@ final class RecalculerMoyennesOnEvaluationModifieeHandlerTest extends TestCase ($this->handler)(new EvaluationModifiee( evaluationId: $evaluationId, - title: 'Titre modifié', - evaluationDate: new DateTimeImmutable('2026-02-15'), + oldTitle: 'Test Evaluation', + newTitle: 'Titre modifié', + oldDescription: null, + newDescription: null, + oldCoefficient: 1.0, + newCoefficient: 1.0, + oldEvaluationDate: new DateTimeImmutable('2026-02-15'), + newEvaluationDate: new DateTimeImmutable('2026-02-15'), + oldGradeScale: 20, + newGradeScale: 20, occurredOn: new DateTimeImmutable(), )); @@ -188,8 +212,16 @@ final class RecalculerMoyennesOnEvaluationModifieeHandlerTest extends TestCase ($this->handler)(new EvaluationModifiee( evaluationId: $evaluationId, - title: 'Titre modifié', - evaluationDate: new DateTimeImmutable('2026-02-15'), + oldTitle: 'Test Evaluation', + newTitle: 'Titre modifié', + oldDescription: null, + newDescription: null, + oldCoefficient: 1.0, + newCoefficient: 1.0, + oldEvaluationDate: new DateTimeImmutable('2026-02-15'), + newEvaluationDate: new DateTimeImmutable('2026-02-15'), + oldGradeScale: 20, + newGradeScale: 20, occurredOn: new DateTimeImmutable(), )); @@ -219,8 +251,16 @@ final class RecalculerMoyennesOnEvaluationModifieeHandlerTest extends TestCase ($this->handler)(new EvaluationModifiee( evaluationId: $evaluationId, - title: 'Titre modifié', - evaluationDate: new DateTimeImmutable('2026-02-15'), + oldTitle: 'Test Evaluation', + newTitle: 'Titre modifié', + oldDescription: null, + newDescription: null, + oldCoefficient: 1.0, + newCoefficient: 1.0, + oldEvaluationDate: new DateTimeImmutable('2026-02-15'), + newEvaluationDate: new DateTimeImmutable('2026-02-15'), + oldGradeScale: 20, + newGradeScale: 20, occurredOn: new DateTimeImmutable(), )); diff --git a/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnNoteModifieeHandlerTest.php b/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnNoteModifieeHandlerTest.php index b5d536c..536ae4a 100644 --- a/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnNoteModifieeHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnNoteModifieeHandlerTest.php @@ -118,6 +118,7 @@ final class RecalculerMoyennesOnNoteModifieeHandlerTest extends TestCase $event = new NoteModifiee( gradeId: $grade1->id, evaluationId: (string) $evaluation->id, + studentId: self::STUDENT_ID, oldValue: 14.0, newValue: 18.0, oldStatus: 'graded', @@ -174,6 +175,7 @@ final class RecalculerMoyennesOnNoteModifieeHandlerTest extends TestCase $event = new NoteModifiee( gradeId: $grade->id, evaluationId: (string) $evaluation->id, + studentId: self::STUDENT_ID, oldValue: 10.0, newValue: 14.0, oldStatus: 'graded', @@ -233,6 +235,7 @@ final class RecalculerMoyennesOnNoteModifieeHandlerTest extends TestCase $event = new NoteModifiee( gradeId: GradeId::generate(), evaluationId: (string) $evaluation->id, + studentId: self::STUDENT_ID, oldValue: 10.0, newValue: 14.0, oldStatus: 'graded', @@ -264,6 +267,7 @@ final class RecalculerMoyennesOnNoteModifieeHandlerTest extends TestCase $event = new NoteModifiee( gradeId: GradeId::generate(), evaluationId: (string) $unknownEvalId, + studentId: self::STUDENT_ID, oldValue: 10.0, newValue: 14.0, oldStatus: 'graded', diff --git a/frontend/e2e/student-grades.spec.ts b/frontend/e2e/student-grades.spec.ts index 987ffb5..aa20ec0 100644 --- a/frontend/e2e/student-grades.spec.ts +++ b/frontend/e2e/student-grades.spec.ts @@ -335,10 +335,12 @@ test.describe('Student Grade Consultation (Story 6.6)', () => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/dashboard/student-grades`); - await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 }); - - // Badges should be visible on new grades - await expect(page.locator('.badge-new').first()).toBeVisible({ timeout: 5000 }); + // Le badge disparaît 3 s après le chargement (markGradesSeen). On attend + // directement le badge dans un seul expect pour éviter une fenêtre de + // course entre « grade-card visible » et « badge encore affiché ». + await expect(page.locator('.grade-card .badge-new').first()).toBeVisible({ + timeout: 15000, + }); }); // =========================================================================