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.
343 lines
13 KiB
PHP
343 lines
13 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\EvaluationModifiee;
|
|
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\GradeStatus;
|
|
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
|
use App\Scolarite\Domain\Service\AverageCalculator;
|
|
use App\Scolarite\Infrastructure\EventHandler\RecalculerMoyennesOnEvaluationModifieeHandler;
|
|
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 RecalculerMoyennesOnEvaluationModifieeHandlerTest 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_1 = '22222222-2222-2222-2222-222222222222';
|
|
private const string STUDENT_2 = '33333333-3333-3333-3333-333333333333';
|
|
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 RecalculerMoyennesOnEvaluationModifieeHandler $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: RecalculerMoyennesOnEvaluationModifieeHandlerTest::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 RecalculerMoyennesOnEvaluationModifieeHandler(
|
|
tenantContext: $tenantContext,
|
|
service: $service,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function itRecalculatesStatisticsWhenEvaluationModified(): void
|
|
{
|
|
$evaluationId = $this->seedPublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 14.0, GradeStatus::GRADED],
|
|
[self::STUDENT_2, 8.0, GradeStatus::GRADED],
|
|
],
|
|
);
|
|
|
|
($this->handler)(new EvaluationModifiee(
|
|
evaluationId: $evaluationId,
|
|
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(),
|
|
));
|
|
|
|
$stats = $this->evalStatsRepo->findByEvaluation($evaluationId);
|
|
|
|
self::assertNotNull($stats);
|
|
self::assertSame(11.0, $stats->average);
|
|
self::assertSame(8.0, $stats->min);
|
|
self::assertSame(14.0, $stats->max);
|
|
self::assertSame(2, $stats->gradedCount);
|
|
}
|
|
|
|
#[Test]
|
|
public function itRecalculatesStudentAveragesWhenEvaluationModified(): void
|
|
{
|
|
$evaluationId = $this->seedPublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 16.0, GradeStatus::GRADED],
|
|
[self::STUDENT_2, 12.0, GradeStatus::GRADED],
|
|
],
|
|
);
|
|
|
|
($this->handler)(new EvaluationModifiee(
|
|
evaluationId: $evaluationId,
|
|
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(),
|
|
));
|
|
|
|
$student1Avg = $this->studentAvgRepo->findSubjectAverage(
|
|
UserId::fromString(self::STUDENT_1),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
TenantId::fromString(self::TENANT_ID),
|
|
);
|
|
|
|
self::assertNotNull($student1Avg);
|
|
self::assertSame(16.0, $student1Avg['average']);
|
|
|
|
$student2Avg = $this->studentAvgRepo->findSubjectAverage(
|
|
UserId::fromString(self::STUDENT_2),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
TenantId::fromString(self::TENANT_ID),
|
|
);
|
|
|
|
self::assertNotNull($student2Avg);
|
|
self::assertSame(12.0, $student2Avg['average']);
|
|
}
|
|
|
|
#[Test]
|
|
public function itRecalculatesGeneralAverageForAllStudents(): void
|
|
{
|
|
$evaluationId = $this->seedPublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 14.0, GradeStatus::GRADED],
|
|
],
|
|
);
|
|
|
|
($this->handler)(new EvaluationModifiee(
|
|
evaluationId: $evaluationId,
|
|
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(),
|
|
));
|
|
|
|
$generalAvg = $this->studentAvgRepo->findGeneralAverageForStudent(
|
|
UserId::fromString(self::STUDENT_1),
|
|
self::PERIOD_ID,
|
|
TenantId::fromString(self::TENANT_ID),
|
|
);
|
|
|
|
self::assertSame(14.0, $generalAvg);
|
|
}
|
|
|
|
#[Test]
|
|
public function itRecalculatesStatsButNotStudentAveragesWhenNotPublished(): void
|
|
{
|
|
$evaluationId = $this->seedUnpublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 14.0, GradeStatus::GRADED],
|
|
],
|
|
);
|
|
|
|
($this->handler)(new EvaluationModifiee(
|
|
evaluationId: $evaluationId,
|
|
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(),
|
|
));
|
|
|
|
// Les stats sont calculées (le handler ne filtre pas sur publication)
|
|
$stats = $this->evalStatsRepo->findByEvaluation($evaluationId);
|
|
self::assertNotNull($stats);
|
|
self::assertSame(14.0, $stats->average);
|
|
|
|
// Mais pas de recalcul des moyennes élèves (recalculerTousEleves filtre)
|
|
self::assertNull($this->studentAvgRepo->findSubjectAverage(
|
|
UserId::fromString(self::STUDENT_1),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
self::PERIOD_ID,
|
|
TenantId::fromString(self::TENANT_ID),
|
|
));
|
|
}
|
|
|
|
#[Test]
|
|
public function itExcludesAbsentStudentsFromStatistics(): void
|
|
{
|
|
$evaluationId = $this->seedPublishedEvaluationWithGrades(
|
|
grades: [
|
|
[self::STUDENT_1, 18.0, GradeStatus::GRADED],
|
|
[self::STUDENT_2, null, GradeStatus::ABSENT],
|
|
],
|
|
);
|
|
|
|
($this->handler)(new EvaluationModifiee(
|
|
evaluationId: $evaluationId,
|
|
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(),
|
|
));
|
|
|
|
$stats = $this->evalStatsRepo->findByEvaluation($evaluationId);
|
|
|
|
self::assertNotNull($stats);
|
|
self::assertSame(18.0, $stats->average);
|
|
self::assertSame(1, $stats->gradedCount);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{0: string, 1: float|null, 2: GradeStatus}> $grades
|
|
*/
|
|
private function seedPublishedEvaluationWithGrades(
|
|
array $grades,
|
|
float $coefficient = 1.0,
|
|
): EvaluationId {
|
|
return $this->seedEvaluationWithGrades($grades, $coefficient, published: true);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{0: string, 1: float|null, 2: GradeStatus}> $grades
|
|
*/
|
|
private function seedUnpublishedEvaluationWithGrades(
|
|
array $grades,
|
|
float $coefficient = 1.0,
|
|
): EvaluationId {
|
|
return $this->seedEvaluationWithGrades($grades, $coefficient, published: false);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{0: string, 1: float|null, 2: GradeStatus}> $grades
|
|
*/
|
|
private function seedEvaluationWithGrades(
|
|
array $grades,
|
|
float $coefficient = 1.0,
|
|
bool $published = true,
|
|
): EvaluationId {
|
|
$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($coefficient),
|
|
now: $now,
|
|
);
|
|
|
|
if ($published) {
|
|
$evaluation->publierNotes($now);
|
|
}
|
|
|
|
$evaluation->pullDomainEvents();
|
|
$this->evaluationRepo->save($evaluation);
|
|
|
|
foreach ($grades as [$studentId, $value, $status]) {
|
|
$grade = Grade::saisir(
|
|
tenantId: $tenantId,
|
|
evaluationId: $evaluation->id,
|
|
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 $evaluation->id;
|
|
}
|
|
}
|