feat: Désignation de remplaçants temporaires avec corrections sécurité

Permet aux administrateurs de désigner un enseignant remplaçant pour
un autre enseignant absent, sur des classes et matières précises, pour
une période donnée. Le dashboard enseignant affiche les remplacements
actifs avec les noms de classes/matières au lieu des identifiants bruts.

Inclut les corrections de la code review :
- Requête findActiveByTenant qui excluait les remplacements en cours
  mais incluait les futurs (manquait start_date <= :at)
- Validation tenant et rôle enseignant dans le handler de désignation
  pour empêcher l'affectation cross-tenant ou de non-enseignants
- Validation structurée du payload classes (Assert\Collection + UUID)
  pour éviter les erreurs serveur sur payloads malformés
- API replaced-classes enrichie avec les noms classe/matière
This commit is contained in:
2026-02-16 14:32:37 +01:00
parent fdc26eb334
commit c856dfdcda
63 changed files with 7694 additions and 236 deletions

View File

@@ -0,0 +1,331 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\TeacherReplacement;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\RemplacementDesigne;
use App\Scolarite\Domain\Event\RemplacementTermine;
use App\Scolarite\Domain\Exception\DatesRemplacementInvalidesException;
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
use App\Scolarite\Domain\Exception\RemplacementSameTeacherException;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\ReplacementStatus;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TeacherReplacementTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string REPLACED_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string REPLACEMENT_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440011';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string CREATED_BY_ID = '550e8400-e29b-41d4-a716-446655440099';
#[Test]
public function designerCreatesActiveReplacement(): void
{
$replacement = $this->createReplacement();
self::assertSame(ReplacementStatus::ACTIVE, $replacement->status);
self::assertNull($replacement->endedAt);
}
#[Test]
public function designerRecordsRemplacementDesigneEvent(): void
{
$replacement = $this->createReplacement();
$events = $replacement->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(RemplacementDesigne::class, $events[0]);
self::assertSame($replacement->id, $events[0]->replacementId);
self::assertTrue($replacement->replacedTeacherId->equals($events[0]->replacedTeacherId));
self::assertTrue($replacement->replacementTeacherId->equals($events[0]->replacementTeacherId));
}
#[Test]
public function designerSetsAllProperties(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$replacedTeacherId = UserId::fromString(self::REPLACED_TEACHER_ID);
$replacementTeacherId = UserId::fromString(self::REPLACEMENT_TEACHER_ID);
$startDate = new DateTimeImmutable('2026-03-01');
$endDate = new DateTimeImmutable('2026-03-31');
$classes = $this->createClasses();
$createdBy = UserId::fromString(self::CREATED_BY_ID);
$now = new DateTimeImmutable('2026-02-15 10:00:00');
$replacement = TeacherReplacement::designer(
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
classes: $classes,
reason: 'Congé maladie',
createdBy: $createdBy,
now: $now,
);
self::assertTrue($replacement->tenantId->equals($tenantId));
self::assertTrue($replacement->replacedTeacherId->equals($replacedTeacherId));
self::assertTrue($replacement->replacementTeacherId->equals($replacementTeacherId));
self::assertEquals($startDate, $replacement->startDate);
self::assertEquals($endDate, $replacement->endDate);
self::assertCount(1, $replacement->classes);
self::assertSame('Congé maladie', $replacement->reason);
self::assertTrue($replacement->createdBy->equals($createdBy));
self::assertEquals($now, $replacement->createdAt);
self::assertEquals($now, $replacement->updatedAt);
self::assertNull($replacement->endedAt);
}
#[Test]
public function designerThrowsWhenSameTeacher(): void
{
$this->expectException(RemplacementSameTeacherException::class);
$sameTeacherId = UserId::fromString(self::REPLACED_TEACHER_ID);
TeacherReplacement::designer(
tenantId: TenantId::fromString(self::TENANT_ID),
replacedTeacherId: $sameTeacherId,
replacementTeacherId: $sameTeacherId,
startDate: new DateTimeImmutable('2026-03-01'),
endDate: new DateTimeImmutable('2026-03-31'),
classes: $this->createClasses(),
reason: null,
createdBy: UserId::fromString(self::CREATED_BY_ID),
now: new DateTimeImmutable('2026-02-15 10:00:00'),
);
}
#[Test]
public function designerThrowsWhenEndDateBeforeStartDate(): void
{
$this->expectException(DatesRemplacementInvalidesException::class);
TeacherReplacement::designer(
tenantId: TenantId::fromString(self::TENANT_ID),
replacedTeacherId: UserId::fromString(self::REPLACED_TEACHER_ID),
replacementTeacherId: UserId::fromString(self::REPLACEMENT_TEACHER_ID),
startDate: new DateTimeImmutable('2026-03-31'),
endDate: new DateTimeImmutable('2026-03-01'),
classes: $this->createClasses(),
reason: null,
createdBy: UserId::fromString(self::CREATED_BY_ID),
now: new DateTimeImmutable('2026-02-15 10:00:00'),
);
}
#[Test]
public function isActiveReturnsTrueWhenWithinDateRange(): void
{
$replacement = $this->createReplacement();
self::assertTrue($replacement->isActive(new DateTimeImmutable('2026-03-15')));
}
#[Test]
public function isActiveReturnsTrueOnStartDate(): void
{
$replacement = $this->createReplacement();
self::assertTrue($replacement->isActive(new DateTimeImmutable('2026-03-01')));
}
#[Test]
public function isActiveReturnsTrueOnEndDate(): void
{
$replacement = $this->createReplacement();
self::assertTrue($replacement->isActive(new DateTimeImmutable('2026-03-31')));
}
#[Test]
public function isActiveReturnsFalseBeforeStartDate(): void
{
$replacement = $this->createReplacement();
self::assertFalse($replacement->isActive(new DateTimeImmutable('2026-02-28')));
}
#[Test]
public function isActiveReturnsFalseAfterEndDate(): void
{
$replacement = $this->createReplacement();
self::assertFalse($replacement->isActive(new DateTimeImmutable('2026-04-01')));
}
#[Test]
public function isActiveReturnsFalseWhenEnded(): void
{
$replacement = $this->createReplacement();
$replacement->terminer(new DateTimeImmutable('2026-03-15'));
self::assertFalse($replacement->isActive(new DateTimeImmutable('2026-03-15')));
}
#[Test]
public function isExpiredReturnsTrueAfterEndDate(): void
{
$replacement = $this->createReplacement();
self::assertTrue($replacement->isExpired(new DateTimeImmutable('2026-04-01')));
}
#[Test]
public function isExpiredReturnsFalseWhenStillActive(): void
{
$replacement = $this->createReplacement();
self::assertFalse($replacement->isExpired(new DateTimeImmutable('2026-03-15')));
}
#[Test]
public function isExpiredReturnsFalseWhenAlreadyEnded(): void
{
$replacement = $this->createReplacement();
$replacement->terminer(new DateTimeImmutable('2026-03-15'));
self::assertFalse($replacement->isExpired(new DateTimeImmutable('2026-04-01')));
}
#[Test]
public function terminerChangesStatusAndRecordsEvent(): void
{
$replacement = $this->createReplacement();
$replacement->pullDomainEvents();
$at = new DateTimeImmutable('2026-03-15 14:00:00');
$replacement->terminer($at);
self::assertSame(ReplacementStatus::ENDED, $replacement->status);
self::assertEquals($at, $replacement->endedAt);
self::assertEquals($at, $replacement->updatedAt);
$events = $replacement->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(RemplacementTermine::class, $events[0]);
self::assertSame($replacement->id, $events[0]->replacementId);
}
#[Test]
public function terminerThrowsWhenAlreadyEnded(): void
{
$replacement = $this->createReplacement();
$replacement->terminer(new DateTimeImmutable('2026-03-15'));
$this->expectException(RemplacementDejaTermineException::class);
$replacement->terminer(new DateTimeImmutable('2026-03-16'));
}
#[Test]
public function couvreClasseMatiereReturnsTrueForMatchingPair(): void
{
$replacement = $this->createReplacement();
$pair = new ClassSubjectPair(
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
);
self::assertTrue($replacement->couvreClasseMatiere($pair));
}
#[Test]
public function couvreClasseMatiereReturnsFalseForNonMatchingPair(): void
{
$replacement = $this->createReplacement();
$pair = new ClassSubjectPair(
ClassId::fromString('550e8400-e29b-41d4-a716-446655440099'),
SubjectId::fromString(self::SUBJECT_ID),
);
self::assertFalse($replacement->couvreClasseMatiere($pair));
}
#[Test]
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
{
$id = TeacherReplacementId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$replacedTeacherId = UserId::fromString(self::REPLACED_TEACHER_ID);
$replacementTeacherId = UserId::fromString(self::REPLACEMENT_TEACHER_ID);
$startDate = new DateTimeImmutable('2026-03-01');
$endDate = new DateTimeImmutable('2026-03-31');
$classes = $this->createClasses();
$createdBy = UserId::fromString(self::CREATED_BY_ID);
$createdAt = new DateTimeImmutable('2026-02-15 10:00:00');
$endedAt = new DateTimeImmutable('2026-03-20 10:00:00');
$updatedAt = new DateTimeImmutable('2026-03-20 10:00:00');
$replacement = TeacherReplacement::reconstitute(
id: $id,
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
status: ReplacementStatus::ENDED,
classes: $classes,
reason: 'Congé maladie',
createdBy: $createdBy,
createdAt: $createdAt,
endedAt: $endedAt,
updatedAt: $updatedAt,
);
self::assertTrue($replacement->id->equals($id));
self::assertTrue($replacement->tenantId->equals($tenantId));
self::assertTrue($replacement->replacedTeacherId->equals($replacedTeacherId));
self::assertTrue($replacement->replacementTeacherId->equals($replacementTeacherId));
self::assertEquals($startDate, $replacement->startDate);
self::assertEquals($endDate, $replacement->endDate);
self::assertSame(ReplacementStatus::ENDED, $replacement->status);
self::assertCount(1, $replacement->classes);
self::assertSame('Congé maladie', $replacement->reason);
self::assertTrue($replacement->createdBy->equals($createdBy));
self::assertEquals($createdAt, $replacement->createdAt);
self::assertEquals($endedAt, $replacement->endedAt);
self::assertEquals($updatedAt, $replacement->updatedAt);
self::assertEmpty($replacement->pullDomainEvents());
}
private function createReplacement(): TeacherReplacement
{
return TeacherReplacement::designer(
tenantId: TenantId::fromString(self::TENANT_ID),
replacedTeacherId: UserId::fromString(self::REPLACED_TEACHER_ID),
replacementTeacherId: UserId::fromString(self::REPLACEMENT_TEACHER_ID),
startDate: new DateTimeImmutable('2026-03-01'),
endDate: new DateTimeImmutable('2026-03-31'),
classes: $this->createClasses(),
reason: null,
createdBy: UserId::fromString(self::CREATED_BY_ID),
now: new DateTimeImmutable('2026-02-15 10:00:00'),
);
}
/** @return array<ClassSubjectPair> */
private function createClasses(): array
{
return [
new ClassSubjectPair(
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
),
];
}
}