*/ private array $affectationResults = []; protected function setUp(): void { $this->evaluationRepository = new InMemoryEvaluationRepository(); $this->gradeRepository = new InMemoryGradeRepository(); $this->replacementRepository = new InMemoryTeacherReplacementRepository(); $this->clock = new class implements Clock { public function now(): DateTimeImmutable { return new DateTimeImmutable('2026-03-27 10:00:00'); } }; $this->affectationResults = []; $this->setTeacherAffecte(self::TEACHER_ID); $this->seedEvaluation(); } private function setTeacherAffecte(string $teacherId): void { $this->affectationResults[$teacherId] = true; } #[Test] public function itSavesNewGrades(): void { $handler = $this->createHandler(); $command = new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: self::TEACHER_ID, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'], ['studentId' => self::STUDENT_2_ID, 'value' => 12.0, 'status' => 'graded'], ], ); $savedGrades = $handler($command); self::assertCount(2, $savedGrades); self::assertSame(15.5, $savedGrades[0]->value->value); self::assertSame(12.0, $savedGrades[1]->value->value); } #[Test] public function itPersistsGradesInRepository(): void { $handler = $this->createHandler(); $command = new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: self::TEACHER_ID, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'], ], ); $handler($command); $tenantId = TenantId::fromString(self::TENANT_ID); $grades = $this->gradeRepository->findByEvaluation( EvaluationId::fromString(self::EVALUATION_ID), $tenantId, ); self::assertCount(1, $grades); self::assertSame(15.5, $grades[0]->value->value); } #[Test] public function itUpdatesExistingGrades(): void { $handler = $this->createHandler(); $handler(new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: self::TEACHER_ID, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'], ], )); $handler(new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: self::TEACHER_ID, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => 18.0, 'status' => 'graded'], ], )); $tenantId = TenantId::fromString(self::TENANT_ID); $grades = $this->gradeRepository->findByEvaluation( EvaluationId::fromString(self::EVALUATION_ID), $tenantId, ); self::assertCount(1, $grades); self::assertSame(18.0, $grades[0]->value->value); } #[Test] public function itSavesAbsentGrade(): void { $handler = $this->createHandler(); $command = new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: self::TEACHER_ID, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'absent'], ], ); $savedGrades = $handler($command); self::assertSame(GradeStatus::ABSENT, $savedGrades[0]->status); self::assertNull($savedGrades[0]->value); } #[Test] public function itSavesDispensedGrade(): void { $handler = $this->createHandler(); $command = new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: self::TEACHER_ID, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'dispensed'], ], ); $savedGrades = $handler($command); self::assertSame(GradeStatus::DISPENSED, $savedGrades[0]->status); self::assertNull($savedGrades[0]->value); } /** @see itThrowsWhenTeacherNotAssigned - renamed, now checks assignment instead of ownership */ #[Test] public function itThrowsWhenValueExceedsGradeScale(): void { $handler = $this->createHandler(); $this->expectException(ValeurNoteInvalideException::class); $handler(new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: self::TEACHER_ID, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => 25.0, 'status' => 'graded'], ], )); } #[Test] public function itThrowsWhenGradedWithoutValue(): void { $handler = $this->createHandler(); $this->expectException(NoteRequiseException::class); $handler(new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: self::TEACHER_ID, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'graded'], ], )); } #[Test] public function itThrowsWhenTeacherNotAssigned(): void { $handler = $this->createHandler(); $unassignedTeacher = '550e8400-e29b-41d4-a716-446655440099'; $this->expectException(NonProprietaireDeLEvaluationException::class); $handler(new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: $unassignedTeacher, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'], ], )); } #[Test] public function itAllowsReplacementTeacherToSave(): void { $replacementTeacherId = '550e8400-e29b-41d4-a716-446655440088'; $now = $this->clock->now(); $replacement = TeacherReplacement::designer( tenantId: TenantId::fromString(self::TENANT_ID), replacedTeacherId: UserId::fromString(self::TEACHER_ID), replacementTeacherId: UserId::fromString($replacementTeacherId), startDate: $now->modify('-1 day'), endDate: $now->modify('+7 days'), classes: [new ClassSubjectPair( ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), )], reason: 'Maladie', createdBy: UserId::generate(), now: $now->modify('-1 day'), ); $this->replacementRepository->save($replacement); $handler = $this->createHandler(); $savedGrades = $handler(new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: $replacementTeacherId, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => 14.0, 'status' => 'graded'], ], )); self::assertCount(1, $savedGrades); self::assertSame((string) UserId::fromString($replacementTeacherId), (string) $savedGrades[0]->createdBy); } #[Test] public function itBlocksEvaluationOwnerWithRemovedAssignment(): void { // Teacher IS the evaluation owner but has no active assignment $this->affectationResults = []; // Remove all assignments $handler = $this->createHandler(); $this->expectException(NonProprietaireDeLEvaluationException::class); $handler(new SaveGradesCommand( tenantId: self::TENANT_ID, evaluationId: self::EVALUATION_ID, teacherId: self::TEACHER_ID, grades: [ ['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'], ], )); } private function createHandler(): SaveGradesHandler { return new SaveGradesHandler( $this->evaluationRepository, $this->gradeRepository, $this->createAutorisationChecker(), $this->clock, ); } private function createAutorisationChecker(): AutorisationSaisieNotesChecker { $test = $this; $affectationChecker = new class($test) implements EnseignantAffectationChecker { public function __construct(private readonly SaveGradesHandlerTest $test) { } public function estAffecte( UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId, ): bool { return $this->test->isTeacherAffecte((string) $teacherId); } }; return new AutorisationSaisieNotesChecker( $affectationChecker, $this->replacementRepository, ); } public function isTeacherAffecte(string $teacherId): bool { return $this->affectationResults[$teacherId] ?? false; } private function seedEvaluation(): void { $evaluation = Evaluation::reconstitute( id: EvaluationId::fromString(self::EVALUATION_ID), tenantId: TenantId::fromString(self::TENANT_ID), classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), teacherId: UserId::fromString(self::TEACHER_ID), title: 'ContrĂ´le chapitre 5', description: null, evaluationDate: new DateTimeImmutable('2026-04-15'), gradeScale: new GradeScale(20), coefficient: new Coefficient(1.0), status: EvaluationStatus::PUBLISHED, createdAt: new DateTimeImmutable('2026-03-12 10:00:00'), updatedAt: new DateTimeImmutable('2026-03-12 10:00:00'), ); $this->evaluationRepository->save($evaluation); } }