feat: Provisionner automatiquement un nouvel établissement
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.
This commit is contained in:
@@ -9,6 +9,8 @@ use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\PublishGrades\PublishGradesCommand;
|
||||
use App\Scolarite\Application\Command\PublishGrades\PublishGradesHandler;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
|
||||
use App\Scolarite\Domain\Exception\AucuneNoteSaisieException;
|
||||
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
|
||||
use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
|
||||
@@ -22,6 +24,7 @@ use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||
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;
|
||||
@@ -37,12 +40,17 @@ final class PublishGradesHandlerTest extends TestCase
|
||||
|
||||
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
|
||||
{
|
||||
@@ -50,9 +58,16 @@ final class PublishGradesHandlerTest extends TestCase
|
||||
}
|
||||
};
|
||||
|
||||
$this->affectationResults = [];
|
||||
$this->affectationResults[self::TEACHER_ID] = true;
|
||||
$this->seedEvaluation();
|
||||
}
|
||||
|
||||
public function isTeacherAffecte(string $teacherId): bool
|
||||
{
|
||||
return $this->affectationResults[$teacherId] ?? false;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPublishesGradesWhenGradesExist(): void
|
||||
{
|
||||
@@ -107,18 +122,18 @@ final class PublishGradesHandlerTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenTeacherNotOwner(): void
|
||||
public function itThrowsWhenTeacherNotAssigned(): void
|
||||
{
|
||||
$this->seedGrade();
|
||||
$handler = $this->createHandler();
|
||||
$otherTeacher = '550e8400-e29b-41d4-a716-446655440099';
|
||||
$unassignedTeacher = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
$this->expectException(NonProprietaireDeLEvaluationException::class);
|
||||
|
||||
$handler(new PublishGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: $otherTeacher,
|
||||
teacherId: $unassignedTeacher,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -145,10 +160,35 @@ final class PublishGradesHandlerTest extends TestCase
|
||||
return new PublishGradesHandler(
|
||||
$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 PublishGradesHandlerTest $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,
|
||||
);
|
||||
}
|
||||
|
||||
private function seedEvaluation(): void
|
||||
{
|
||||
$evaluation = Evaluation::reconstitute(
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\SaveAppreciation\SaveAppreciationCommand;
|
||||
use App\Scolarite\Application\Command\SaveAppreciation\SaveAppreciationHandler;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
|
||||
use App\Scolarite\Domain\Exception\AppreciationTropLongueException;
|
||||
use App\Scolarite\Domain\Exception\GradeNotFoundException;
|
||||
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
|
||||
@@ -22,6 +24,7 @@ use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||
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;
|
||||
@@ -39,13 +42,18 @@ final class SaveAppreciationHandlerTest extends TestCase
|
||||
|
||||
private InMemoryEvaluationRepository $evaluationRepository;
|
||||
private InMemoryGradeRepository $gradeRepository;
|
||||
private InMemoryTeacherReplacementRepository $replacementRepository;
|
||||
private Clock $clock;
|
||||
private string $gradeId;
|
||||
|
||||
/** @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
|
||||
{
|
||||
@@ -53,9 +61,16 @@ final class SaveAppreciationHandlerTest extends TestCase
|
||||
}
|
||||
};
|
||||
|
||||
$this->affectationResults = [];
|
||||
$this->affectationResults[self::TEACHER_ID] = true;
|
||||
$this->seedEvaluationAndGrade();
|
||||
}
|
||||
|
||||
public function isTeacherAffecte(string $teacherId): bool
|
||||
{
|
||||
return $this->affectationResults[$teacherId] ?? false;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSavesAppreciation(): void
|
||||
{
|
||||
@@ -144,10 +159,35 @@ final class SaveAppreciationHandlerTest extends TestCase
|
||||
return new SaveAppreciationHandler(
|
||||
$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 SaveAppreciationHandlerTest $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,
|
||||
);
|
||||
}
|
||||
|
||||
private function seedEvaluationAndGrade(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
@@ -9,6 +9,8 @@ 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;
|
||||
@@ -18,8 +20,11 @@ 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;
|
||||
@@ -36,12 +41,17 @@ final class SaveGradesHandlerTest extends TestCase
|
||||
|
||||
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
|
||||
{
|
||||
@@ -49,9 +59,16 @@ final class SaveGradesHandlerTest extends TestCase
|
||||
}
|
||||
};
|
||||
|
||||
$this->affectationResults = [];
|
||||
$this->setTeacherAffecte(self::TEACHER_ID);
|
||||
$this->seedEvaluation();
|
||||
}
|
||||
|
||||
private function setTeacherAffecte(string $teacherId): void
|
||||
{
|
||||
$this->affectationResults[$teacherId] = true;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSavesNewGrades(): void
|
||||
{
|
||||
@@ -169,24 +186,7 @@ final class SaveGradesHandlerTest extends TestCase
|
||||
self::assertNull($savedGrades[0]->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenTeacherNotOwner(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$otherTeacher = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
$this->expectException(NonProprietaireDeLEvaluationException::class);
|
||||
|
||||
$handler(new SaveGradesCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
evaluationId: self::EVALUATION_ID,
|
||||
teacherId: $otherTeacher,
|
||||
grades: [
|
||||
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
/** @see itThrowsWhenTeacherNotAssigned - renamed, now checks assignment instead of ownership */
|
||||
#[Test]
|
||||
public function itThrowsWhenValueExceedsGradeScale(): void
|
||||
{
|
||||
@@ -221,15 +221,119 @@ final class SaveGradesHandlerTest extends TestCase
|
||||
));
|
||||
}
|
||||
|
||||
#[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(
|
||||
|
||||
@@ -112,6 +112,14 @@ final class UploadSubmissionAttachmentHandlerTest extends TestCase
|
||||
public function delete(string $path): void
|
||||
{
|
||||
}
|
||||
|
||||
public function readStream(string $path): mixed
|
||||
{
|
||||
/** @var resource $stream */
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
|
||||
return $stream;
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
|
||||
@@ -10,8 +10,11 @@ use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -28,7 +31,29 @@ final class GetBlockedDatesHandlerTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->calendarRepository = new InMemorySchoolCalendarRepository();
|
||||
$this->handler = new GetBlockedDatesHandler($this->calendarRepository);
|
||||
|
||||
$rulesChecker = new class implements HomeworkRulesChecker {
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new GetBlockedDatesHandler(
|
||||
$this->calendarRepository,
|
||||
$rulesChecker,
|
||||
$clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -110,6 +135,93 @@ final class GetBlockedDatesHandlerTest extends TestCase
|
||||
self::assertCount(5, $vacations);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsRuleHardBlockedDates(): void
|
||||
{
|
||||
$rulesChecker = new class implements HomeworkRulesChecker {
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
// Block Tuesday March 3
|
||||
if ($dueDate->format('Y-m-d') === '2026-03-03') {
|
||||
return new HomeworkRulesCheckResult(
|
||||
warnings: [new \App\Scolarite\Application\Port\RuleWarning('minimum_delay', 'Délai minimum non respecté')],
|
||||
bloquant: true,
|
||||
);
|
||||
}
|
||||
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock);
|
||||
|
||||
$result = ($handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-06',
|
||||
));
|
||||
|
||||
$ruleBlocked = array_filter($result, static fn ($d) => $d->type === 'rule_hard');
|
||||
self::assertCount(1, $ruleBlocked);
|
||||
$blocked = array_values($ruleBlocked)[0];
|
||||
self::assertSame('2026-03-03', $blocked->date);
|
||||
self::assertSame('Délai minimum non respecté', $blocked->reason);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsRuleSoftWarningDates(): void
|
||||
{
|
||||
$rulesChecker = new class implements HomeworkRulesChecker {
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
if ($dueDate->format('Y-m-d') === '2026-03-04') {
|
||||
return new HomeworkRulesCheckResult(
|
||||
warnings: [new \App\Scolarite\Application\Port\RuleWarning('no_monday_after', 'Devoirs pour lundi déconseillés')],
|
||||
bloquant: false,
|
||||
);
|
||||
}
|
||||
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock);
|
||||
|
||||
$result = ($handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-06',
|
||||
));
|
||||
|
||||
$ruleSoft = array_filter($result, static fn ($d) => $d->type === 'rule_soft');
|
||||
self::assertCount(1, $ruleSoft);
|
||||
$soft = array_values($ruleSoft)[0];
|
||||
self::assertSame('2026-03-04', $soft->date);
|
||||
self::assertSame('rule_soft', $soft->type);
|
||||
}
|
||||
|
||||
private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetClassStatisticsDetail;
|
||||
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\PeriodInfo;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\GetClassStatisticsDetailHandler;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\GetClassStatisticsDetailQuery;
|
||||
use App\Scolarite\Domain\Service\AverageCalculator;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetClassStatisticsDetailHandlerTest extends TestCase
|
||||
{
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenNoPeriodFound(): void
|
||||
{
|
||||
$handler = $this->createHandler(periodInfo: null);
|
||||
|
||||
$result = $handler($this->query());
|
||||
|
||||
self::assertNull($result->average);
|
||||
self::assertSame(0.0, $result->successRate);
|
||||
self::assertSame([], $result->students);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itComputesClassStatisticsFromGrades(): void
|
||||
{
|
||||
$this->reader->feedClassGrades([8.0, 10.0, 12.0, 14.0, 16.0]);
|
||||
$this->reader->feedMonthlyAverages([
|
||||
['month' => '2026-01', 'average' => 11.0],
|
||||
['month' => '2026-02', 'average' => 12.5],
|
||||
]);
|
||||
$this->reader->feedStudentAverages([
|
||||
['studentId' => 's1', 'studentName' => 'Alice Dupont', 'average' => 14.0],
|
||||
['studentId' => 's2', 'studentName' => 'Bob Martin', 'average' => 7.0],
|
||||
]);
|
||||
|
||||
$handler = $this->createHandler(periodInfo: $this->currentPeriod());
|
||||
$result = $handler($this->query());
|
||||
|
||||
self::assertSame(12.0, $result->average);
|
||||
self::assertSame(80.0, $result->successRate); // 4/5 >= 10
|
||||
self::assertSame([0, 0, 0, 1, 2, 1, 1, 0], $result->distribution);
|
||||
self::assertCount(2, $result->evolution);
|
||||
self::assertCount(2, $result->students);
|
||||
self::assertFalse($result->students[0]->inDifficulty); // Alice 14.0 >= 8.0
|
||||
self::assertTrue($result->students[1]->inDifficulty); // Bob 7.0 < 8.0
|
||||
}
|
||||
|
||||
private function query(): GetClassStatisticsDetailQuery
|
||||
{
|
||||
return new GetClassStatisticsDetailQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
);
|
||||
}
|
||||
|
||||
private function createHandler(?PeriodInfo $periodInfo): GetClassStatisticsDetailHandler
|
||||
{
|
||||
$periodFinder = new class($periodInfo) implements PeriodFinder {
|
||||
public function __construct(private readonly ?PeriodInfo $info)
|
||||
{
|
||||
}
|
||||
|
||||
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
||||
{
|
||||
return $this->info;
|
||||
}
|
||||
};
|
||||
|
||||
return new GetClassStatisticsDetailHandler(
|
||||
$this->reader,
|
||||
$periodFinder,
|
||||
new TeacherStatisticsCalculator(),
|
||||
new AverageCalculator(),
|
||||
);
|
||||
}
|
||||
|
||||
private function currentPeriod(): PeriodInfo
|
||||
{
|
||||
return new PeriodInfo(
|
||||
periodId: 'period-1',
|
||||
startDate: new DateTimeImmutable('2026-01-05'),
|
||||
endDate: new DateTimeImmutable('2026-03-31'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetEvaluationDifficulty;
|
||||
|
||||
use App\Scolarite\Application\Query\GetEvaluationDifficulty\GetEvaluationDifficultyHandler;
|
||||
use App\Scolarite\Application\Query\GetEvaluationDifficulty\GetEvaluationDifficultyQuery;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetEvaluationDifficultyHandlerTest extends TestCase
|
||||
{
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
private GetEvaluationDifficultyHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
$this->handler = new GetEvaluationDifficultyHandler(
|
||||
$this->reader,
|
||||
new TeacherStatisticsCalculator(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenNoEvaluations(): void
|
||||
{
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEvaluationDifficultyWithComparison(): void
|
||||
{
|
||||
$this->reader->feedEvaluationDifficulties([
|
||||
[
|
||||
'evaluationId' => 'eval-1',
|
||||
'title' => 'Contrôle chapitre 5',
|
||||
'classId' => 'class-1',
|
||||
'className' => '6ème A',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Mathématiques',
|
||||
'date' => '2026-03-15',
|
||||
'average' => 12.0,
|
||||
'gradedCount' => 25,
|
||||
],
|
||||
]);
|
||||
|
||||
// Other teachers' averages for same subject
|
||||
$this->reader->feedOtherTeachersAverages([10.0, 11.0, 13.0]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('Contrôle chapitre 5', $result[0]->title);
|
||||
self::assertSame(12.0, $result[0]->average);
|
||||
self::assertEqualsWithDelta(11.33, $result[0]->subjectAverage, 0.01);
|
||||
self::assertNotNull($result[0]->percentile);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesNoOtherTeachersForComparison(): void
|
||||
{
|
||||
$this->reader->feedEvaluationDifficulties([
|
||||
[
|
||||
'evaluationId' => 'eval-1',
|
||||
'title' => 'Test unique',
|
||||
'classId' => 'class-1',
|
||||
'className' => '6ème A',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Musique',
|
||||
'date' => '2026-03-15',
|
||||
'average' => 14.0,
|
||||
'gradedCount' => 20,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->reader->feedOtherTeachersAverages([]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertNull($result[0]->subjectAverage);
|
||||
self::assertNull($result[0]->percentile);
|
||||
}
|
||||
|
||||
private function query(): GetEvaluationDifficultyQuery
|
||||
{
|
||||
return new GetEvaluationDifficultyQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetStudentProgression;
|
||||
|
||||
use App\Scolarite\Application\Query\GetStudentProgression\GetStudentProgressionHandler;
|
||||
use App\Scolarite\Application\Query\GetStudentProgression\GetStudentProgressionQuery;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetStudentProgressionHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
private GetStudentProgressionHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
$this->handler = new GetStudentProgressionHandler(
|
||||
$this->reader,
|
||||
new TeacherStatisticsCalculator(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyProgressionWhenNoGrades(): void
|
||||
{
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertSame([], $result->grades);
|
||||
self::assertNull($result->trendLine);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsSingleGradeWithNoTrendLine(): void
|
||||
{
|
||||
$this->reader->feedGradeHistory([
|
||||
['date' => '2026-01-15', 'value' => 12.0, 'evaluationTitle' => 'Contrôle 1'],
|
||||
]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(1, $result->grades);
|
||||
self::assertSame('2026-01-15', $result->grades[0]->date);
|
||||
self::assertSame(12.0, $result->grades[0]->value);
|
||||
self::assertNull($result->trendLine);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itComputesTrendLineFromMultipleGrades(): void
|
||||
{
|
||||
$this->reader->feedGradeHistory([
|
||||
['date' => '2026-01-15', 'value' => 10.0, 'evaluationTitle' => 'Contrôle 1'],
|
||||
['date' => '2026-02-10', 'value' => 12.0, 'evaluationTitle' => 'Contrôle 2'],
|
||||
['date' => '2026-03-05', 'value' => 14.0, 'evaluationTitle' => 'Contrôle 3'],
|
||||
]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(3, $result->grades);
|
||||
self::assertNotNull($result->trendLine);
|
||||
self::assertGreaterThan(0, $result->trendLine->slope); // Positive trend
|
||||
}
|
||||
|
||||
private function query(): GetStudentProgressionQuery
|
||||
{
|
||||
return new GetStudentProgressionQuery(
|
||||
studentId: '550e8400-e29b-41d4-a716-446655440050',
|
||||
subjectId: '550e8400-e29b-41d4-a716-446655440030',
|
||||
classId: '550e8400-e29b-41d4-a716-446655440020',
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||
tenantId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetTeacherStatisticsOverview;
|
||||
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\PeriodInfo;
|
||||
use App\Scolarite\Application\Query\GetTeacherStatisticsOverview\GetTeacherStatisticsOverviewHandler;
|
||||
use App\Scolarite\Application\Query\GetTeacherStatisticsOverview\GetTeacherStatisticsOverviewQuery;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetTeacherStatisticsOverviewHandlerTest extends TestCase
|
||||
{
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenNoPeriodFound(): void
|
||||
{
|
||||
$handler = $this->createHandler(periodInfo: null);
|
||||
|
||||
$result = $handler(new GetTeacherStatisticsOverviewQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsClassOverviewDtos(): void
|
||||
{
|
||||
$this->reader->feedClassesSummary([
|
||||
[
|
||||
'classId' => 'class-1',
|
||||
'className' => '6ème A',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Mathématiques',
|
||||
'evaluationCount' => 3,
|
||||
'studentCount' => 25,
|
||||
'average' => 12.5,
|
||||
'successRate' => 72.0,
|
||||
],
|
||||
[
|
||||
'classId' => 'class-2',
|
||||
'className' => '5ème B',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Mathématiques',
|
||||
'evaluationCount' => 2,
|
||||
'studentCount' => 28,
|
||||
'average' => 10.8,
|
||||
'successRate' => 57.0,
|
||||
],
|
||||
]);
|
||||
|
||||
$handler = $this->createHandler(periodInfo: $this->currentPeriod());
|
||||
|
||||
$result = $handler(new GetTeacherStatisticsOverviewQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('6ème A', $result[0]->className);
|
||||
self::assertSame(12.5, $result[0]->average);
|
||||
self::assertSame(72.0, $result[0]->successRate);
|
||||
self::assertSame('5ème B', $result[1]->className);
|
||||
}
|
||||
|
||||
private function createHandler(?PeriodInfo $periodInfo): GetTeacherStatisticsOverviewHandler
|
||||
{
|
||||
$periodFinder = new class($periodInfo) implements PeriodFinder {
|
||||
public function __construct(private readonly ?PeriodInfo $info)
|
||||
{
|
||||
}
|
||||
|
||||
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
||||
{
|
||||
return $this->info;
|
||||
}
|
||||
};
|
||||
|
||||
return new GetTeacherStatisticsOverviewHandler($this->reader, $periodFinder);
|
||||
}
|
||||
|
||||
private function currentPeriod(): PeriodInfo
|
||||
{
|
||||
return new PeriodInfo(
|
||||
periodId: 'period-1',
|
||||
startDate: new DateTimeImmutable('2026-01-05'),
|
||||
endDate: new DateTimeImmutable('2026-03-31'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Service;
|
||||
|
||||
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\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
|
||||
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AutorisationSaisieNotesCheckerTest extends TestCase
|
||||
{
|
||||
private TenantId $tenantId;
|
||||
private ClassId $classId;
|
||||
private SubjectId $subjectId;
|
||||
private InMemoryTeacherReplacementRepository $replacementRepository;
|
||||
private DateTimeImmutable $now;
|
||||
|
||||
/** @var array<string, bool> */
|
||||
private array $affectationResults = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tenantId = TenantId::generate();
|
||||
$this->classId = ClassId::generate();
|
||||
$this->subjectId = SubjectId::generate();
|
||||
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
|
||||
$this->now = new DateTimeImmutable('2026-04-14 10:00:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsTrueWhenTeacherIsAffected(): void
|
||||
{
|
||||
$teacherId = UserId::generate();
|
||||
$this->setTeacherAffecte($teacherId);
|
||||
|
||||
$checker = $this->createChecker();
|
||||
$evaluation = $this->createEvaluation();
|
||||
|
||||
self::assertTrue($checker->peutSaisirNotes($teacherId, $evaluation, $this->tenantId, $this->now));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsFalseWhenTeacherIsNotAffectedAndNoReplacement(): void
|
||||
{
|
||||
$teacherId = UserId::generate();
|
||||
|
||||
$checker = $this->createChecker();
|
||||
$evaluation = $this->createEvaluation();
|
||||
|
||||
self::assertFalse($checker->peutSaisirNotes($teacherId, $evaluation, $this->tenantId, $this->now));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsTrueWhenTeacherHasActiveReplacement(): void
|
||||
{
|
||||
$replacementTeacherId = UserId::generate();
|
||||
$this->createActiveReplacement($replacementTeacherId);
|
||||
|
||||
$checker = $this->createChecker();
|
||||
$evaluation = $this->createEvaluation();
|
||||
|
||||
self::assertTrue($checker->peutSaisirNotes($replacementTeacherId, $evaluation, $this->tenantId, $this->now));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsFalseWhenReplacementIsExpired(): void
|
||||
{
|
||||
$replacementTeacherId = UserId::generate();
|
||||
$this->createExpiredReplacement($replacementTeacherId);
|
||||
|
||||
$checker = $this->createChecker();
|
||||
$evaluation = $this->createEvaluation();
|
||||
|
||||
self::assertFalse($checker->peutSaisirNotes($replacementTeacherId, $evaluation, $this->tenantId, $this->now));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsTrueWhenBothAffectedAndActiveReplacement(): void
|
||||
{
|
||||
$teacherId = UserId::generate();
|
||||
$this->setTeacherAffecte($teacherId);
|
||||
$this->createActiveReplacement($teacherId);
|
||||
|
||||
$checker = $this->createChecker();
|
||||
$evaluation = $this->createEvaluation();
|
||||
|
||||
self::assertTrue($checker->peutSaisirNotes($teacherId, $evaluation, $this->tenantId, $this->now));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsFalseWhenReplacementIsOnDifferentClassSubject(): void
|
||||
{
|
||||
$replacementTeacherId = UserId::generate();
|
||||
|
||||
$otherClassId = ClassId::generate();
|
||||
$otherSubjectId = SubjectId::generate();
|
||||
$replacement = TeacherReplacement::designer(
|
||||
tenantId: $this->tenantId,
|
||||
replacedTeacherId: UserId::generate(),
|
||||
replacementTeacherId: $replacementTeacherId,
|
||||
startDate: $this->now->modify('-1 day'),
|
||||
endDate: $this->now->modify('+7 days'),
|
||||
classes: [new ClassSubjectPair($otherClassId, $otherSubjectId)],
|
||||
reason: 'Maladie',
|
||||
createdBy: UserId::generate(),
|
||||
now: $this->now->modify('-1 day'),
|
||||
);
|
||||
$this->replacementRepository->save($replacement);
|
||||
|
||||
$checker = $this->createChecker();
|
||||
$evaluation = $this->createEvaluation();
|
||||
|
||||
self::assertFalse($checker->peutSaisirNotes($replacementTeacherId, $evaluation, $this->tenantId, $this->now));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsFalseWhenReplacementStartsInTheFuture(): void
|
||||
{
|
||||
$replacementTeacherId = UserId::generate();
|
||||
$futureStart = $this->now->modify('+1 day');
|
||||
|
||||
$replacement = TeacherReplacement::designer(
|
||||
tenantId: $this->tenantId,
|
||||
replacedTeacherId: UserId::generate(),
|
||||
replacementTeacherId: $replacementTeacherId,
|
||||
startDate: $futureStart,
|
||||
endDate: $this->now->modify('+14 days'),
|
||||
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
|
||||
reason: 'Congé prévu',
|
||||
createdBy: UserId::generate(),
|
||||
now: $this->now,
|
||||
);
|
||||
$this->replacementRepository->save($replacement);
|
||||
|
||||
$checker = $this->createChecker();
|
||||
$evaluation = $this->createEvaluation();
|
||||
|
||||
self::assertFalse($checker->peutSaisirNotes($replacementTeacherId, $evaluation, $this->tenantId, $this->now));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itShortCircuitsOnAffectationWithoutCheckingReplacement(): void
|
||||
{
|
||||
$teacherId = UserId::generate();
|
||||
$this->setTeacherAffecte($teacherId);
|
||||
|
||||
// No replacement seeded — if it tries to check replacement for an
|
||||
// assigned teacher, the result should still be true (short-circuit)
|
||||
$checker = $this->createChecker();
|
||||
$evaluation = $this->createEvaluation();
|
||||
|
||||
self::assertTrue($checker->peutSaisirNotes($teacherId, $evaluation, $this->tenantId, $this->now));
|
||||
}
|
||||
|
||||
private function setTeacherAffecte(UserId $teacherId): void
|
||||
{
|
||||
$this->affectationResults[(string) $teacherId] = true;
|
||||
}
|
||||
|
||||
private function createChecker(): AutorisationSaisieNotesChecker
|
||||
{
|
||||
$test = $this;
|
||||
$affectationChecker = new class($test) implements EnseignantAffectationChecker {
|
||||
public function __construct(private readonly AutorisationSaisieNotesCheckerTest $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 createEvaluation(): Evaluation
|
||||
{
|
||||
return Evaluation::creer(
|
||||
tenantId: $this->tenantId,
|
||||
classId: $this->classId,
|
||||
subjectId: $this->subjectId,
|
||||
teacherId: UserId::generate(),
|
||||
title: 'Contrôle de maths',
|
||||
description: null,
|
||||
evaluationDate: $this->now,
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(1.0),
|
||||
now: $this->now,
|
||||
);
|
||||
}
|
||||
|
||||
private function createActiveReplacement(UserId $replacementTeacherId): void
|
||||
{
|
||||
$replacement = TeacherReplacement::designer(
|
||||
tenantId: $this->tenantId,
|
||||
replacedTeacherId: UserId::generate(),
|
||||
replacementTeacherId: $replacementTeacherId,
|
||||
startDate: $this->now->modify('-1 day'),
|
||||
endDate: $this->now->modify('+7 days'),
|
||||
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
|
||||
reason: 'Maladie',
|
||||
createdBy: UserId::generate(),
|
||||
now: $this->now->modify('-1 day'),
|
||||
);
|
||||
$this->replacementRepository->save($replacement);
|
||||
}
|
||||
|
||||
private function createExpiredReplacement(UserId $replacementTeacherId): void
|
||||
{
|
||||
$replacement = TeacherReplacement::designer(
|
||||
tenantId: $this->tenantId,
|
||||
replacedTeacherId: UserId::generate(),
|
||||
replacementTeacherId: $replacementTeacherId,
|
||||
startDate: $this->now->modify('-14 days'),
|
||||
endDate: $this->now->modify('-1 day'),
|
||||
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
|
||||
reason: 'Maladie',
|
||||
createdBy: UserId::generate(),
|
||||
now: $this->now->modify('-14 days'),
|
||||
);
|
||||
$this->replacementRepository->save($replacement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Service;
|
||||
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\ClassStatisticsDetailDto;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\StudentAverageDto;
|
||||
use App\Scolarite\Application\Service\StatisticsExporter;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use function str_contains;
|
||||
|
||||
final class StatisticsExporterTest extends TestCase
|
||||
{
|
||||
private StatisticsExporter $exporter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->exporter = new StatisticsExporter();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExportsClassStatisticsToCsv(): void
|
||||
{
|
||||
$stats = new ClassStatisticsDetailDto(
|
||||
average: 12.5,
|
||||
successRate: 72.0,
|
||||
distribution: [0, 0, 1, 2, 3, 2, 1, 0],
|
||||
evolution: [],
|
||||
students: [
|
||||
new StudentAverageDto(
|
||||
studentId: 's1',
|
||||
studentName: 'Alice Dupont',
|
||||
average: 14.0,
|
||||
inDifficulty: false,
|
||||
trend: 'improving',
|
||||
),
|
||||
new StudentAverageDto(
|
||||
studentId: 's2',
|
||||
studentName: 'Bob Martin',
|
||||
average: 7.0,
|
||||
inDifficulty: true,
|
||||
trend: 'declining',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
$csv = $this->exporter->exportClassToCsv($stats, '6ème A', 'Mathématiques');
|
||||
|
||||
self::assertNotSame('', $csv);
|
||||
self::assertTrue(str_contains($csv, '6ème A'));
|
||||
self::assertTrue(str_contains($csv, 'Mathématiques'));
|
||||
self::assertTrue(str_contains($csv, '12.5'));
|
||||
self::assertTrue(str_contains($csv, '72%'));
|
||||
self::assertTrue(str_contains($csv, 'Alice Dupont'));
|
||||
self::assertTrue(str_contains($csv, '14'));
|
||||
self::assertTrue(str_contains($csv, 'Progression'));
|
||||
self::assertTrue(str_contains($csv, 'Bob Martin'));
|
||||
self::assertTrue(str_contains($csv, 'Oui'));
|
||||
self::assertTrue(str_contains($csv, 'Régression'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesEmptyStudentList(): void
|
||||
{
|
||||
$stats = new ClassStatisticsDetailDto(
|
||||
average: null,
|
||||
successRate: 0.0,
|
||||
distribution: [0, 0, 0, 0, 0, 0, 0, 0],
|
||||
evolution: [],
|
||||
students: [],
|
||||
);
|
||||
|
||||
$csv = $this->exporter->exportClassToCsv($stats, '5ème B', 'Français');
|
||||
|
||||
self::assertNotSame('', $csv);
|
||||
self::assertTrue(str_contains($csv, '5ème B'));
|
||||
self::assertTrue(str_contains($csv, 'N/A'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user