Les enseignants ont besoin de moyennes à jour immédiatement après la publication ou modification des notes, sans attendre un batch nocturne. Le système recalcule via Domain Events synchrones : statistiques d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées (normalisation /20), et moyenne générale par élève. Les résultats sont stockés dans des tables dénormalisées avec cache Redis (TTL 5 min). Trois endpoints API exposent les données avec contrôle d'accès par rôle. Une commande console permet le backfill des données historiques au déploiement.
329 lines
12 KiB
PHP
329 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,
|
|
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,
|
|
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,
|
|
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,
|
|
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;
|
|
}
|
|
}
|