Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
358 lines
12 KiB
PHP
358 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Scolarite\Application\Command\SaveGrades;
|
|
|
|
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\Command\SaveGrades\SaveGradesCommand;
|
|
use App\Scolarite\Application\Command\SaveGrades\SaveGradesHandler;
|
|
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
|
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
|
|
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
|
|
use App\Scolarite\Domain\Exception\NoteRequiseException;
|
|
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
|
|
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\EvaluationStatus;
|
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
|
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
|
|
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class SaveGradesHandlerTest 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 TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
|
private const string STUDENT_1_ID = '550e8400-e29b-41d4-a716-446655440050';
|
|
private const string STUDENT_2_ID = '550e8400-e29b-41d4-a716-446655440051';
|
|
|
|
private InMemoryEvaluationRepository $evaluationRepository;
|
|
private InMemoryGradeRepository $gradeRepository;
|
|
private InMemoryTeacherReplacementRepository $replacementRepository;
|
|
private Clock $clock;
|
|
|
|
/** @var array<string, bool> */
|
|
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);
|
|
}
|
|
}
|