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
189 lines
7.3 KiB
PHP
189 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Scolarite\Infrastructure\Console;
|
|
|
|
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\EndReplacement\EndReplacementHandler;
|
|
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
|
|
use App\Scolarite\Domain\Model\TeacherReplacement\ReplacementStatus;
|
|
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
|
use App\Scolarite\Infrastructure\Console\EndExpiredReplacementsCommand;
|
|
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;
|
|
use Psr\Log\NullLogger;
|
|
use Symfony\Component\Console\Tester\CommandTester;
|
|
|
|
final class EndExpiredReplacementsCommandTest 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';
|
|
|
|
private InMemoryTeacherReplacementRepository $repository;
|
|
private Clock $clock;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->repository = new InMemoryTeacherReplacementRepository();
|
|
$this->clock = new class implements Clock {
|
|
public function now(): DateTimeImmutable
|
|
{
|
|
return new DateTimeImmutable('2026-04-15 10:00:00');
|
|
}
|
|
};
|
|
}
|
|
|
|
#[Test]
|
|
public function itEndsExpiredReplacements(): void
|
|
{
|
|
$replacement = $this->createReplacement(
|
|
startDate: '2026-03-01',
|
|
endDate: '2026-03-31',
|
|
);
|
|
$this->repository->save($replacement);
|
|
|
|
$tester = $this->executeCommand();
|
|
|
|
self::assertSame(0, $tester->getStatusCode());
|
|
self::assertStringContainsString('1 remplacement(s) expiré(s) trouvé(s)', $tester->getDisplay());
|
|
self::assertStringContainsString('1 remplacement(s) terminé(s) avec succès.', $tester->getDisplay());
|
|
}
|
|
|
|
#[Test]
|
|
public function itHandlesNoExpiredReplacements(): void
|
|
{
|
|
$replacement = $this->createReplacement(
|
|
startDate: '2026-04-01',
|
|
endDate: '2026-04-30',
|
|
);
|
|
$this->repository->save($replacement);
|
|
|
|
$tester = $this->executeCommand();
|
|
|
|
self::assertSame(0, $tester->getStatusCode());
|
|
self::assertStringContainsString('Aucun remplacement expiré à traiter.', $tester->getDisplay());
|
|
}
|
|
|
|
#[Test]
|
|
public function itHandlesIndividualErrorsGracefully(): void
|
|
{
|
|
// The command's findExpired query uses one repository while the handler's
|
|
// get() uses another that holds an already-terminated copy of the same
|
|
// replacement, simulating a concurrent termination (race condition).
|
|
$commandRepository = new InMemoryTeacherReplacementRepository();
|
|
$activeReplacement = $this->createReplacement(
|
|
startDate: '2026-03-01',
|
|
endDate: '2026-03-31',
|
|
);
|
|
$commandRepository->save($activeReplacement);
|
|
|
|
$handlerRepository = new InMemoryTeacherReplacementRepository();
|
|
$terminatedReplacement = TeacherReplacement::reconstitute(
|
|
id: $activeReplacement->id,
|
|
tenantId: $activeReplacement->tenantId,
|
|
replacedTeacherId: $activeReplacement->replacedTeacherId,
|
|
replacementTeacherId: $activeReplacement->replacementTeacherId,
|
|
startDate: $activeReplacement->startDate,
|
|
endDate: $activeReplacement->endDate,
|
|
status: ReplacementStatus::ENDED,
|
|
classes: $activeReplacement->classes,
|
|
reason: $activeReplacement->reason,
|
|
createdBy: $activeReplacement->createdBy,
|
|
createdAt: $activeReplacement->createdAt,
|
|
endedAt: new DateTimeImmutable('2026-03-31'),
|
|
updatedAt: new DateTimeImmutable('2026-03-31'),
|
|
);
|
|
$handlerRepository->save($terminatedReplacement);
|
|
|
|
$handler = new EndReplacementHandler($handlerRepository, $this->clock);
|
|
|
|
$command = new EndExpiredReplacementsCommand(
|
|
$commandRepository,
|
|
$handler,
|
|
$this->clock,
|
|
new NullLogger(),
|
|
);
|
|
|
|
$tester = new CommandTester($command);
|
|
$tester->execute([]);
|
|
|
|
self::assertSame(0, $tester->getStatusCode());
|
|
self::assertStringContainsString('1 remplacement(s) expiré(s) trouvé(s)', $tester->getDisplay());
|
|
self::assertStringContainsString('déjà terminé', $tester->getDisplay());
|
|
self::assertStringContainsString('0 remplacement(s) terminé(s) avec succès.', $tester->getDisplay());
|
|
}
|
|
|
|
#[Test]
|
|
public function itOutputsCorrectMessagesForMultipleReplacements(): void
|
|
{
|
|
$replacement1 = $this->createReplacement(
|
|
startDate: '2026-02-01',
|
|
endDate: '2026-02-28',
|
|
);
|
|
$replacement2 = $this->createReplacement(
|
|
startDate: '2026-03-01',
|
|
endDate: '2026-03-31',
|
|
replacementTeacherId: '550e8400-e29b-41d4-a716-446655440012',
|
|
);
|
|
$this->repository->save($replacement1);
|
|
$this->repository->save($replacement2);
|
|
|
|
$tester = $this->executeCommand();
|
|
|
|
self::assertSame(0, $tester->getStatusCode());
|
|
self::assertStringContainsString('2 remplacement(s) expiré(s) trouvé(s)', $tester->getDisplay());
|
|
self::assertStringContainsString('2 remplacement(s) terminé(s) avec succès.', $tester->getDisplay());
|
|
}
|
|
|
|
private function executeCommand(): CommandTester
|
|
{
|
|
$handler = new EndReplacementHandler($this->repository, $this->clock);
|
|
|
|
$command = new EndExpiredReplacementsCommand(
|
|
$this->repository,
|
|
$handler,
|
|
$this->clock,
|
|
new NullLogger(),
|
|
);
|
|
|
|
$tester = new CommandTester($command);
|
|
$tester->execute([]);
|
|
|
|
return $tester;
|
|
}
|
|
|
|
private function createReplacement(
|
|
string $startDate = '2026-03-01',
|
|
string $endDate = '2026-03-31',
|
|
string $replacementTeacherId = self::REPLACEMENT_TEACHER_ID,
|
|
): TeacherReplacement {
|
|
return TeacherReplacement::designer(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
replacedTeacherId: UserId::fromString(self::REPLACED_TEACHER_ID),
|
|
replacementTeacherId: UserId::fromString($replacementTeacherId),
|
|
startDate: new DateTimeImmutable($startDate),
|
|
endDate: new DateTimeImmutable($endDate),
|
|
classes: [
|
|
new ClassSubjectPair(
|
|
ClassId::fromString(self::CLASS_ID),
|
|
SubjectId::fromString(self::SUBJECT_ID),
|
|
),
|
|
],
|
|
reason: null,
|
|
createdBy: UserId::fromString(self::CREATED_BY_ID),
|
|
now: new DateTimeImmutable('2026-02-15 10:00:00'),
|
|
);
|
|
}
|
|
}
|