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.
333 lines
12 KiB
PHP
333 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Scolarite\Infrastructure\EventHandler;
|
|
|
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Scolarite\Application\Port\PeriodFinder;
|
|
use App\Scolarite\Application\Port\PeriodInfo;
|
|
use App\Scolarite\Application\Service\RecalculerMoyennesService;
|
|
use App\Scolarite\Domain\Event\NoteModifiee;
|
|
use App\Scolarite\Domain\Event\NoteSaisie;
|
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
|
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
|
use App\Scolarite\Domain\Model\Grade\GradeId;
|
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
|
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
|
use App\Scolarite\Domain\Service\AverageCalculator;
|
|
use App\Scolarite\Infrastructure\EventHandler\RecalculerMoyennesOnNoteModifieeHandler;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationStatisticsRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryStudentAverageRepository;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
|
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class RecalculerMoyennesOnNoteModifieeHandlerTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
public const string PERIOD_ID = '11111111-1111-1111-1111-111111111111';
|
|
private const string STUDENT_ID = '22222222-2222-2222-2222-222222222222';
|
|
private const string TEACHER_ID = '44444444-4444-4444-4444-444444444444';
|
|
private const string CLASS_ID = '55555555-5555-5555-5555-555555555555';
|
|
private const string SUBJECT_ID = '66666666-6666-6666-6666-666666666666';
|
|
|
|
private InMemoryEvaluationRepository $evaluationRepo;
|
|
private InMemoryGradeRepository $gradeRepo;
|
|
private InMemoryEvaluationStatisticsRepository $evalStatsRepo;
|
|
private InMemoryStudentAverageRepository $studentAvgRepo;
|
|
private RecalculerMoyennesOnNoteModifieeHandler $handler;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->evaluationRepo = new InMemoryEvaluationRepository();
|
|
$this->gradeRepo = new InMemoryGradeRepository();
|
|
$this->evalStatsRepo = new InMemoryEvaluationStatisticsRepository();
|
|
$this->studentAvgRepo = new InMemoryStudentAverageRepository();
|
|
|
|
$tenantContext = new TenantContext();
|
|
$tenantContext->setCurrentTenant(new TenantConfig(
|
|
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
|
subdomain: 'test',
|
|
databaseUrl: 'postgresql://test',
|
|
));
|
|
|
|
$periodFinder = new class implements PeriodFinder {
|
|
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
|
{
|
|
return new PeriodInfo(
|
|
periodId: RecalculerMoyennesOnNoteModifieeHandlerTest::PERIOD_ID,
|
|
startDate: new DateTimeImmutable('2026-01-01'),
|
|
endDate: new DateTimeImmutable('2026-03-31'),
|
|
);
|
|
}
|
|
};
|
|
|
|
$service = new RecalculerMoyennesService(
|
|
evaluationRepository: $this->evaluationRepo,
|
|
gradeRepository: $this->gradeRepo,
|
|
evaluationStatisticsRepository: $this->evalStatsRepo,
|
|
studentAverageRepository: $this->studentAvgRepo,
|
|
periodFinder: $periodFinder,
|
|
calculator: new AverageCalculator(),
|
|
);
|
|
|
|
$this->handler = new RecalculerMoyennesOnNoteModifieeHandler(
|
|
tenantContext: $tenantContext,
|
|
evaluationRepository: $this->evaluationRepo,
|
|
gradeRepository: $this->gradeRepo,
|
|
periodFinder: $periodFinder,
|
|
service: $service,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function itRecalculatesStatisticsWhenGradeModifiedAfterPublication(): void
|
|
{
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable();
|
|
|
|
$evaluation = $this->seedPublishedEvaluation();
|
|
|
|
// Deux notes initiales
|
|
$grade1 = $this->seedGrade($evaluation->id, self::STUDENT_ID, 14.0, GradeStatus::GRADED);
|
|
$this->seedGrade($evaluation->id, '77777777-7777-7777-7777-777777777777', 10.0, GradeStatus::GRADED);
|
|
|
|
// Simuler modification de la note
|
|
$grade1->modifier(
|
|
value: new GradeValue(18.0),
|
|
status: GradeStatus::GRADED,
|
|
gradeScale: new GradeScale(20),
|
|
modifiedBy: UserId::fromString(self::TEACHER_ID),
|
|
now: $now,
|
|
);
|
|
$grade1->pullDomainEvents();
|
|
$this->gradeRepo->save($grade1);
|
|
|
|
$event = new NoteModifiee(
|
|
gradeId: $grade1->id,
|
|
evaluationId: (string) $evaluation->id,
|
|
studentId: self::STUDENT_ID,
|
|
oldValue: 14.0,
|
|
newValue: 18.0,
|
|
oldStatus: 'graded',
|
|
newStatus: 'graded',
|
|
modifiedBy: self::TEACHER_ID,
|
|
occurredOn: $now,
|
|
);
|
|
|
|
($this->handler)($event);
|
|
|
|
// Statistiques recalculées
|
|
$stats = $this->evalStatsRepo->findByEvaluation($evaluation->id);
|
|
self::assertNotNull($stats);
|
|
self::assertSame(14.0, $stats->average); // (18+10)/2
|
|
self::assertSame(10.0, $stats->min);
|
|
self::assertSame(18.0, $stats->max);
|
|
|
|
// Moyenne matière recalculée pour l'élève
|
|
$subjectAvg = $this->studentAvgRepo->findSubjectAverage(
|
|
UserId::fromString(self::STUDENT_ID),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
$tenantId,
|
|
);
|
|
|
|
self::assertNotNull($subjectAvg);
|
|
self::assertSame(18.0, $subjectAvg['average']);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDoesNothingWhenGradesNotYetPublished(): void
|
|
{
|
|
$now = new DateTimeImmutable();
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
|
|
// Évaluation NON publiée
|
|
$evaluation = Evaluation::creer(
|
|
tenantId: $tenantId,
|
|
classId: ClassId::fromString(self::CLASS_ID),
|
|
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
|
title: 'Test',
|
|
description: null,
|
|
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
|
gradeScale: new GradeScale(20),
|
|
coefficient: new Coefficient(1.0),
|
|
now: $now,
|
|
);
|
|
$evaluation->pullDomainEvents();
|
|
$this->evaluationRepo->save($evaluation);
|
|
|
|
$grade = $this->seedGrade($evaluation->id, self::STUDENT_ID, 14.0, GradeStatus::GRADED);
|
|
|
|
$event = new NoteModifiee(
|
|
gradeId: $grade->id,
|
|
evaluationId: (string) $evaluation->id,
|
|
studentId: self::STUDENT_ID,
|
|
oldValue: 10.0,
|
|
newValue: 14.0,
|
|
oldStatus: 'graded',
|
|
newStatus: 'graded',
|
|
modifiedBy: self::TEACHER_ID,
|
|
occurredOn: $now,
|
|
);
|
|
|
|
($this->handler)($event);
|
|
|
|
self::assertNull($this->evalStatsRepo->findByEvaluation($evaluation->id));
|
|
}
|
|
|
|
#[Test]
|
|
public function itRecalculatesOnNoteSaisieWhenAlreadyPublished(): void
|
|
{
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable();
|
|
|
|
$evaluation = $this->seedPublishedEvaluation();
|
|
|
|
$grade = $this->seedGrade($evaluation->id, self::STUDENT_ID, 16.0, GradeStatus::GRADED);
|
|
|
|
$event = new NoteSaisie(
|
|
gradeId: $grade->id,
|
|
evaluationId: (string) $evaluation->id,
|
|
studentId: self::STUDENT_ID,
|
|
value: 16.0,
|
|
status: 'graded',
|
|
createdBy: self::TEACHER_ID,
|
|
occurredOn: $now,
|
|
);
|
|
|
|
($this->handler)($event);
|
|
|
|
$stats = $this->evalStatsRepo->findByEvaluation($evaluation->id);
|
|
self::assertNotNull($stats);
|
|
self::assertSame(16.0, $stats->average);
|
|
|
|
$subjectAvg = $this->studentAvgRepo->findSubjectAverage(
|
|
UserId::fromString(self::STUDENT_ID),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
$tenantId,
|
|
);
|
|
|
|
self::assertNotNull($subjectAvg);
|
|
self::assertSame(16.0, $subjectAvg['average']);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDoesNothingWhenGradeNotFound(): void
|
|
{
|
|
$now = new DateTimeImmutable();
|
|
$evaluation = $this->seedPublishedEvaluation();
|
|
|
|
$event = new NoteModifiee(
|
|
gradeId: GradeId::generate(),
|
|
evaluationId: (string) $evaluation->id,
|
|
studentId: self::STUDENT_ID,
|
|
oldValue: 10.0,
|
|
newValue: 14.0,
|
|
oldStatus: 'graded',
|
|
newStatus: 'graded',
|
|
modifiedBy: self::TEACHER_ID,
|
|
occurredOn: $now,
|
|
);
|
|
|
|
($this->handler)($event);
|
|
|
|
// Les stats sont recalculées (car l'évaluation est publiée),
|
|
// mais pas de moyenne élève (grade introuvable)
|
|
$subjectAvg = $this->studentAvgRepo->findSubjectAverage(
|
|
UserId::fromString(self::STUDENT_ID),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
TenantId::fromString(self::TENANT_ID),
|
|
);
|
|
|
|
self::assertNull($subjectAvg);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDoesNothingWhenEvaluationNotFound(): void
|
|
{
|
|
$now = new DateTimeImmutable();
|
|
$unknownEvalId = EvaluationId::generate();
|
|
|
|
$event = new NoteModifiee(
|
|
gradeId: GradeId::generate(),
|
|
evaluationId: (string) $unknownEvalId,
|
|
studentId: self::STUDENT_ID,
|
|
oldValue: 10.0,
|
|
newValue: 14.0,
|
|
oldStatus: 'graded',
|
|
newStatus: 'graded',
|
|
modifiedBy: self::TEACHER_ID,
|
|
occurredOn: $now,
|
|
);
|
|
|
|
($this->handler)($event);
|
|
|
|
self::assertNull($this->evalStatsRepo->findByEvaluation($unknownEvalId));
|
|
}
|
|
|
|
private function seedPublishedEvaluation(): Evaluation
|
|
{
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable();
|
|
|
|
$evaluation = Evaluation::creer(
|
|
tenantId: $tenantId,
|
|
classId: ClassId::fromString(self::CLASS_ID),
|
|
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
|
title: 'Test Evaluation',
|
|
description: null,
|
|
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
|
gradeScale: new GradeScale(20),
|
|
coefficient: new Coefficient(1.0),
|
|
now: $now,
|
|
);
|
|
$evaluation->publierNotes($now);
|
|
$evaluation->pullDomainEvents();
|
|
$this->evaluationRepo->save($evaluation);
|
|
|
|
return $evaluation;
|
|
}
|
|
|
|
private function seedGrade(
|
|
EvaluationId $evaluationId,
|
|
string $studentId,
|
|
?float $value,
|
|
GradeStatus $status,
|
|
): Grade {
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable();
|
|
|
|
$grade = Grade::saisir(
|
|
tenantId: $tenantId,
|
|
evaluationId: $evaluationId,
|
|
studentId: UserId::fromString($studentId),
|
|
value: $value !== null ? new GradeValue($value) : null,
|
|
status: $status,
|
|
gradeScale: new GradeScale(20),
|
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
|
now: $now,
|
|
);
|
|
$grade->pullDomainEvents();
|
|
$this->gradeRepo->save($grade);
|
|
|
|
return $grade;
|
|
}
|
|
}
|