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, + }); }); // =========================================================================