Files
Classeo/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php
Mathias STRASSER 80ce289b86
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: Tracer automatiquement les événements notes et évaluations dans l'audit trail
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.
2026-04-23 05:41:35 +02:00

380 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Grade;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\NoteModifiee;
use App\Scolarite\Domain\Event\NoteSaisie;
use App\Scolarite\Domain\Exception\AppreciationTropLongueException;
use App\Scolarite\Domain\Exception\NoteRequiseException;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use function str_repeat;
final class GradeTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string EVALUATION_ID = '550e8400-e29b-41d4-a716-446655440040';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function saisirCreatesGradedGrade(): void
{
$grade = $this->createGrade();
self::assertSame(GradeStatus::GRADED, $grade->status);
self::assertNotNull($grade->value);
self::assertSame(15.5, $grade->value->value);
}
#[Test]
public function saisirRecordsNoteSaisieEvent(): void
{
$grade = $this->createGrade();
$events = $grade->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(NoteSaisie::class, $events[0]);
self::assertSame($grade->id, $events[0]->gradeId);
self::assertSame(15.5, $events[0]->value);
self::assertSame('graded', $events[0]->status);
}
#[Test]
public function saisirSetsAllProperties(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$evaluationId = EvaluationId::fromString(self::EVALUATION_ID);
$studentId = UserId::fromString(self::STUDENT_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$now = new DateTimeImmutable('2026-03-27 10:00:00');
$value = new GradeValue(15.5);
$gradeScale = new GradeScale(20);
$grade = Grade::saisir(
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: GradeStatus::GRADED,
gradeScale: $gradeScale,
createdBy: $teacherId,
now: $now,
);
self::assertTrue($grade->tenantId->equals($tenantId));
self::assertTrue($grade->evaluationId->equals($evaluationId));
self::assertTrue($grade->studentId->equals($studentId));
self::assertTrue($grade->createdBy->equals($teacherId));
self::assertSame(15.5, $grade->value->value);
self::assertSame(GradeStatus::GRADED, $grade->status);
self::assertEquals($now, $grade->createdAt);
self::assertEquals($now, $grade->updatedAt);
}
#[Test]
public function saisirCreatesAbsentGrade(): void
{
$grade = Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: null,
status: GradeStatus::ABSENT,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
self::assertSame(GradeStatus::ABSENT, $grade->status);
self::assertNull($grade->value);
}
#[Test]
public function saisirCreatesDispensedGrade(): void
{
$grade = Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: null,
status: GradeStatus::DISPENSED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
self::assertSame(GradeStatus::DISPENSED, $grade->status);
self::assertNull($grade->value);
}
#[Test]
public function saisirThrowsWhenGradedWithoutValue(): void
{
$this->expectException(NoteRequiseException::class);
Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: null,
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
}
#[Test]
public function saisirThrowsWhenValueExceedsGradeScale(): void
{
$this->expectException(ValeurNoteInvalideException::class);
Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: new GradeValue(25.0),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
}
#[Test]
public function modifierUpdatesValueAndRecordsEvent(): void
{
$grade = $this->createGrade();
$grade->pullDomainEvents();
$modifiedAt = new DateTimeImmutable('2026-03-27 14:00:00');
$modifierId = UserId::fromString(self::TEACHER_ID);
$grade->modifier(
value: new GradeValue(18.0),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
modifiedBy: $modifierId,
now: $modifiedAt,
);
self::assertSame(18.0, $grade->value->value);
self::assertEquals($modifiedAt, $grade->updatedAt);
$events = $grade->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(NoteModifiee::class, $events[0]);
self::assertSame(self::STUDENT_ID, $events[0]->studentId);
self::assertSame(15.5, $events[0]->oldValue);
self::assertSame(18.0, $events[0]->newValue);
self::assertSame('graded', $events[0]->oldStatus);
self::assertSame('graded', $events[0]->newStatus);
self::assertSame(self::TEACHER_ID, $events[0]->modifiedBy);
}
#[Test]
public function modifierChangesToAbsent(): void
{
$grade = $this->createGrade();
$grade->pullDomainEvents();
$grade->modifier(
value: null,
status: GradeStatus::ABSENT,
gradeScale: new GradeScale(20),
modifiedBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 14:00:00'),
);
self::assertSame(GradeStatus::ABSENT, $grade->status);
self::assertNull($grade->value);
}
#[Test]
public function modifierThrowsWhenGradedWithoutValue(): void
{
$grade = $this->createGrade();
$this->expectException(NoteRequiseException::class);
$grade->modifier(
value: null,
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
modifiedBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 14:00:00'),
);
}
#[Test]
public function modifierThrowsWhenValueExceedsGradeScale(): void
{
$grade = $this->createGrade();
$this->expectException(ValeurNoteInvalideException::class);
$grade->modifier(
value: new GradeValue(25.0),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
modifiedBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 14:00:00'),
);
}
#[Test]
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
{
$gradeId = GradeId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$evaluationId = EvaluationId::fromString(self::EVALUATION_ID);
$studentId = UserId::fromString(self::STUDENT_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$createdAt = new DateTimeImmutable('2026-03-27 10:00:00');
$updatedAt = new DateTimeImmutable('2026-03-27 14:00:00');
$value = new GradeValue(15.5);
$grade = Grade::reconstitute(
id: $gradeId,
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: GradeStatus::GRADED,
createdBy: $teacherId,
createdAt: $createdAt,
updatedAt: $updatedAt,
);
self::assertTrue($grade->id->equals($gradeId));
self::assertTrue($grade->tenantId->equals($tenantId));
self::assertTrue($grade->evaluationId->equals($evaluationId));
self::assertTrue($grade->studentId->equals($studentId));
self::assertTrue($grade->createdBy->equals($teacherId));
self::assertSame(15.5, $grade->value->value);
self::assertSame(GradeStatus::GRADED, $grade->status);
self::assertEquals($createdAt, $grade->createdAt);
self::assertEquals($updatedAt, $grade->updatedAt);
self::assertEmpty($grade->pullDomainEvents());
}
#[Test]
public function saisirAppreciationSetsAppreciation(): void
{
$grade = $this->createGrade();
$now = new DateTimeImmutable('2026-03-31 10:00:00');
$grade->saisirAppreciation('Très bon travail', $now);
self::assertSame('Très bon travail', $grade->appreciation);
self::assertEquals($now, $grade->appreciationUpdatedAt);
self::assertEquals($now, $grade->updatedAt);
}
#[Test]
public function saisirAppreciationAcceptsNull(): void
{
$grade = $this->createGrade();
$grade->saisirAppreciation('Temporaire', new DateTimeImmutable('2026-03-31 09:00:00'));
$grade->saisirAppreciation(null, new DateTimeImmutable('2026-03-31 10:00:00'));
self::assertNull($grade->appreciation);
}
#[Test]
public function saisirAppreciationAcceptsEmptyString(): void
{
$grade = $this->createGrade();
$grade->saisirAppreciation('Temporaire', new DateTimeImmutable('2026-03-31 09:00:00'));
$grade->saisirAppreciation('', new DateTimeImmutable('2026-03-31 10:00:00'));
self::assertNull($grade->appreciation);
}
#[Test]
public function saisirAppreciationThrowsWhenTooLong(): void
{
$grade = $this->createGrade();
$this->expectException(AppreciationTropLongueException::class);
$grade->saisirAppreciation(str_repeat('a', 501), new DateTimeImmutable('2026-03-31 10:00:00'));
}
#[Test]
public function saisirAppreciationAcceptsMaxLength(): void
{
$grade = $this->createGrade();
$grade->saisirAppreciation(str_repeat('a', 500), new DateTimeImmutable('2026-03-31 10:00:00'));
self::assertSame(500, mb_strlen($grade->appreciation ?? ''));
}
#[Test]
public function newGradeHasNullAppreciation(): void
{
$grade = $this->createGrade();
self::assertNull($grade->appreciation);
self::assertNull($grade->appreciationUpdatedAt);
}
#[Test]
public function reconstituteRestoresAppreciation(): void
{
$gradeId = GradeId::generate();
$createdAt = new DateTimeImmutable('2026-03-27 10:00:00');
$updatedAt = new DateTimeImmutable('2026-03-27 14:00:00');
$appreciationUpdatedAt = new DateTimeImmutable('2026-03-31 10:00:00');
$grade = Grade::reconstitute(
id: $gradeId,
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: new GradeValue(15.5),
status: GradeStatus::GRADED,
createdBy: UserId::fromString(self::TEACHER_ID),
createdAt: $createdAt,
updatedAt: $updatedAt,
appreciation: 'Bon travail',
appreciationUpdatedAt: $appreciationUpdatedAt,
);
self::assertSame('Bon travail', $grade->appreciation);
self::assertEquals($appreciationUpdatedAt, $grade->appreciationUpdatedAt);
}
private function createGrade(): Grade
{
return Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: new GradeValue(15.5),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
}
}