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.
205 lines
7.9 KiB
PHP
205 lines
7.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Scolarite\Infrastructure\EventHandler;
|
|
|
|
use App\Scolarite\Domain\Event\EvaluationCreee;
|
|
use App\Scolarite\Domain\Event\EvaluationModifiee;
|
|
use App\Scolarite\Domain\Event\EvaluationSupprimee;
|
|
use App\Scolarite\Domain\Event\NotesPubliees;
|
|
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
|
use App\Scolarite\Infrastructure\EventHandler\AuditEvaluationEventsHandler;
|
|
use DateTimeImmutable;
|
|
use Doctrine\DBAL\Connection;
|
|
|
|
use const JSON_THROW_ON_ERROR;
|
|
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Ramsey\Uuid\Uuid;
|
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
|
|
/**
|
|
* Vérifie le round-trip DB de l'audit des événements Evaluation : le handler,
|
|
* connecté à l'AuditLogger réel, persiste `old_values`/`new_values` complets
|
|
* (title, description, coefficient, evaluation_date, grade_scale) dans `audit_log`.
|
|
*/
|
|
final class AuditEvaluationEventsHandlerFunctionalTest extends KernelTestCase
|
|
{
|
|
private Connection $connection;
|
|
private AuditEvaluationEventsHandler $handler;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
self::bootKernel();
|
|
|
|
/** @var Connection $connection */
|
|
$connection = static::getContainer()->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<string, mixed>
|
|
*/
|
|
private static function decodePayload(mixed $raw): array
|
|
{
|
|
self::assertIsString($raw);
|
|
|
|
/** @var array<string, mixed> $decoded */
|
|
$decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
|
|
|
return $decoded;
|
|
}
|
|
}
|