Files
Classeo/backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnNotesPublieesHandlerTest.php
Mathias STRASSER aedde6707e
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
feat: Calculer automatiquement les moyennes après chaque saisie de notes
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.
2026-03-31 16:43:10 +02:00

302 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\NotesPubliees;
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\RecalculerMoyennesOnNotesPublieesHandler;
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 RecalculerMoyennesOnNotesPublieesHandlerTest 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 RecalculerMoyennesOnNotesPublieesHandler $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: RecalculerMoyennesOnNotesPublieesHandlerTest::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 RecalculerMoyennesOnNotesPublieesHandler(
tenantContext: $tenantContext,
evaluationRepository: $this->evaluationRepo,
gradeRepository: $this->gradeRepo,
periodFinder: $periodFinder,
service: $service,
);
}
#[Test]
public function itCalculatesEvaluationStatisticsOnPublication(): void
{
$evaluationId = $this->seedEvaluationWithGrades(
grades: [
[self::STUDENT_1, 14.0, GradeStatus::GRADED],
[self::STUDENT_2, 8.0, GradeStatus::GRADED],
],
);
($this->handler)(new NotesPubliees(
evaluationId: $evaluationId,
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(11.0, $stats->median);
self::assertSame(2, $stats->gradedCount);
}
#[Test]
public function itExcludesAbsentAndDispensedFromStatistics(): void
{
$evaluationId = $this->seedEvaluationWithGrades(
grades: [
[self::STUDENT_1, 16.0, GradeStatus::GRADED],
[self::STUDENT_2, null, GradeStatus::ABSENT],
],
);
($this->handler)(new NotesPubliees(
evaluationId: $evaluationId,
occurredOn: new DateTimeImmutable(),
));
$stats = $this->evalStatsRepo->findByEvaluation($evaluationId);
self::assertNotNull($stats);
self::assertSame(16.0, $stats->average);
self::assertSame(1, $stats->gradedCount);
}
#[Test]
public function itCalculatesSubjectAverageForEachStudent(): void
{
$evaluationId = $this->seedEvaluationWithGrades(
grades: [
[self::STUDENT_1, 15.0, GradeStatus::GRADED],
[self::STUDENT_2, 10.0, GradeStatus::GRADED],
],
);
($this->handler)(new NotesPubliees(
evaluationId: $evaluationId,
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(15.0, $student1Avg['average']);
self::assertSame(1, $student1Avg['gradeCount']);
$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(10.0, $student2Avg['average']);
}
#[Test]
public function itCalculatesWeightedSubjectAverageAcrossMultipleEvaluations(): void
{
// Première évaluation publiée (coef 2)
$eval1Id = $this->seedEvaluationWithGrades(
grades: [[self::STUDENT_1, 16.0, GradeStatus::GRADED]],
coefficient: 2.0,
published: true,
);
// Publier la première évaluation d'abord
($this->handler)(new NotesPubliees(
evaluationId: $eval1Id,
occurredOn: new DateTimeImmutable(),
));
// Deuxième évaluation publiée (coef 1)
$eval2Id = $this->seedEvaluationWithGrades(
grades: [[self::STUDENT_1, 10.0, GradeStatus::GRADED]],
coefficient: 1.0,
published: true,
);
($this->handler)(new NotesPubliees(
evaluationId: $eval2Id,
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);
// (16×2 + 10×1) / (2+1) = 42/3 = 14.0
self::assertSame(14.0, $student1Avg['average']);
self::assertSame(2, $student1Avg['gradeCount']);
}
#[Test]
public function itCalculatesGeneralAverage(): void
{
$evaluationId = $this->seedEvaluationWithGrades(
grades: [[self::STUDENT_1, 14.0, GradeStatus::GRADED]],
);
($this->handler)(new NotesPubliees(
evaluationId: $evaluationId,
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 itDoesNothingWhenEvaluationNotFound(): void
{
$unknownId = EvaluationId::generate();
($this->handler)(new NotesPubliees(
evaluationId: $unknownId,
occurredOn: new DateTimeImmutable(),
));
self::assertNull($this->evalStatsRepo->findByEvaluation($unknownId));
}
/**
* @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;
}
}