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,324 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Post;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\DesignateReplacement\DesignateReplacementHandler;
use App\Scolarite\Infrastructure\Api\Processor\CreateTeacherReplacementProcessor;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecision;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class CreateTeacherReplacementProcessorTest 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 ADMIN_USER_ID = '550e8400-e29b-41d4-a716-446655440099';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string OTHER_TENANT_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440012';
private const string NON_TEACHER_USER_ID = '550e8400-e29b-41d4-a716-446655440050';
private InMemoryTeacherReplacementRepository $replacementRepository;
private InMemoryUserRepository $userRepository;
private TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
$this->userRepository = new InMemoryUserRepository();
$this->tenantContext = new TenantContext();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-15 10:00:00');
}
};
$this->seedTestData();
}
#[Test]
public function itCreatesReplacementSuccessfully(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = $this->createResource();
$result = $processor->process($data, new Post());
self::assertInstanceOf(TeacherReplacementResource::class, $result);
self::assertNotNull($result->id);
self::assertSame(self::REPLACED_TEACHER_ID, $result->replacedTeacherId);
self::assertSame(self::REPLACEMENT_TEACHER_ID, $result->replacementTeacherId);
self::assertSame('2026-03-01', $result->startDate);
self::assertSame('2026-03-31', $result->endDate);
self::assertSame('active', $result->status);
self::assertSame('Congé maladie', $result->reason);
self::assertCount(1, $result->classes);
}
#[Test]
public function itRejectsUnauthorizedAccess(): void
{
$processor = $this->createProcessor(granted: false);
$this->setTenant();
$this->expectException(AccessDeniedHttpException::class);
$processor->process($this->createResource(), new Post());
}
#[Test]
public function itRejectsRequestWithoutTenant(): void
{
$processor = $this->createProcessor(granted: true);
$this->expectException(UnauthorizedHttpException::class);
$processor->process($this->createResource(), new Post());
}
#[Test]
public function itMapsSameTeacherExceptionToBadRequest(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = $this->createResource();
$data->replacementTeacherId = self::REPLACED_TEACHER_ID;
$this->expectException(BadRequestHttpException::class);
$processor->process($data, new Post());
}
#[Test]
public function itMapsInvalidDatesExceptionToBadRequest(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = $this->createResource();
$data->startDate = '2026-03-31';
$data->endDate = '2026-03-01';
$this->expectException(BadRequestHttpException::class);
$processor->process($data, new Post());
}
#[Test]
public function itMapsUserNotFoundExceptionToNotFound(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = $this->createResource();
$data->replacedTeacherId = '550e8400-e29b-41d4-a716-446655440088';
$this->expectException(NotFoundHttpException::class);
$processor->process($data, new Post());
}
#[Test]
public function itMapsInvalidUuidToBadRequest(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = $this->createResource();
$data->replacedTeacherId = 'not-a-valid-uuid';
$this->expectException(BadRequestHttpException::class);
$processor->process($data, new Post());
}
#[Test]
public function itMapsTenantMismatchExceptionToBadRequest(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = $this->createResource();
$data->replacedTeacherId = self::OTHER_TENANT_TEACHER_ID;
$this->expectException(BadRequestHttpException::class);
$processor->process($data, new Post());
}
#[Test]
public function itMapsNonTeacherExceptionToBadRequest(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$data = $this->createResource();
$data->replacedTeacherId = self::NON_TEACHER_USER_ID;
$this->expectException(BadRequestHttpException::class);
$processor->process($data, new Post());
}
private function seedTestData(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$replacedTeacher = User::reconstitute(
id: UserId::fromString(self::REPLACED_TEACHER_ID),
email: new Email('replaced@example.com'),
roles: [Role::PROF],
tenantId: $tenantId,
schoolName: 'École Test',
statut: StatutCompte::EN_ATTENTE,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15'),
hashedPassword: null,
activatedAt: null,
consentementParental: null,
);
$this->userRepository->save($replacedTeacher);
$replacementTeacher = User::reconstitute(
id: UserId::fromString(self::REPLACEMENT_TEACHER_ID),
email: new Email('replacement@example.com'),
roles: [Role::PROF],
tenantId: $tenantId,
schoolName: 'École Test',
statut: StatutCompte::EN_ATTENTE,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15'),
hashedPassword: null,
activatedAt: null,
consentementParental: null,
);
$this->userRepository->save($replacementTeacher);
// Enseignant d'un autre tenant
$otherTenantTeacher = User::reconstitute(
id: UserId::fromString(self::OTHER_TENANT_TEACHER_ID),
email: new Email('other-tenant@example.com'),
roles: [Role::PROF],
tenantId: TenantId::fromString(self::OTHER_TENANT_ID),
schoolName: 'Autre École',
statut: StatutCompte::EN_ATTENTE,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15'),
hashedPassword: null,
activatedAt: null,
consentementParental: null,
);
$this->userRepository->save($otherTenantTeacher);
// Utilisateur non-enseignant dans le même tenant
$nonTeacherUser = User::reconstitute(
id: UserId::fromString(self::NON_TEACHER_USER_ID),
email: new Email('admin-user@example.com'),
roles: [Role::ADMIN],
tenantId: $tenantId,
schoolName: 'École Test',
statut: StatutCompte::EN_ATTENTE,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15'),
hashedPassword: null,
activatedAt: null,
consentementParental: null,
);
$this->userRepository->save($nonTeacherUser);
}
private function setTenant(): void
{
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_ID),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
}
private function createResource(): TeacherReplacementResource
{
$data = new TeacherReplacementResource();
$data->replacedTeacherId = self::REPLACED_TEACHER_ID;
$data->replacementTeacherId = self::REPLACEMENT_TEACHER_ID;
$data->startDate = '2026-03-01';
$data->endDate = '2026-03-31';
$data->classes = [
['classId' => self::CLASS_ID, 'subjectId' => self::SUBJECT_ID],
];
$data->reason = 'Congé maladie';
return $data;
}
private function createProcessor(bool $granted): CreateTeacherReplacementProcessor
{
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
public function __construct(private readonly bool $granted)
{
}
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
{
return $this->granted;
}
};
$eventBus = new class implements MessageBusInterface {
public function dispatch(object $message, array $stamps = []): Envelope
{
return new Envelope($message);
}
};
$handler = new DesignateReplacementHandler(
$this->replacementRepository,
$this->userRepository,
$this->clock,
);
$securityUser = new SecurityUser(
UserId::fromString(self::ADMIN_USER_ID),
'admin@example.com',
'hashed',
TenantId::fromString(self::TENANT_ID),
[Role::ADMIN->value],
);
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($securityUser);
return new CreateTeacherReplacementProcessor(
$handler,
$this->tenantContext,
$eventBus,
$authChecker,
$security,
);
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Delete;
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\Api\Processor\EndTeacherReplacementProcessor;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AccessDecision;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class EndTeacherReplacementProcessorTest 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 $replacementRepository;
private TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
$this->tenantContext = new TenantContext();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-15 10:00:00');
}
};
}
#[Test]
public function itEndsReplacementSuccessfully(): void
{
$replacement = $this->createAndSaveReplacement();
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$result = $processor->process(
new TeacherReplacementResource(),
new Delete(),
['id' => (string) $replacement->id],
);
self::assertNull($result);
$ended = $this->replacementRepository->get($replacement->id, TenantId::fromString(self::TENANT_ID));
self::assertSame(ReplacementStatus::ENDED, $ended->status);
self::assertNotNull($ended->endedAt);
}
#[Test]
public function itRejectsUnauthorizedAccess(): void
{
$replacement = $this->createAndSaveReplacement();
$processor = $this->createProcessor(granted: false);
$this->setTenant();
$this->expectException(AccessDeniedHttpException::class);
$processor->process(
new TeacherReplacementResource(),
new Delete(),
['id' => (string) $replacement->id],
);
}
#[Test]
public function itRejectsRequestWithoutTenant(): void
{
$replacement = $this->createAndSaveReplacement();
$processor = $this->createProcessor(granted: true);
$this->expectException(UnauthorizedHttpException::class);
$processor->process(
new TeacherReplacementResource(),
new Delete(),
['id' => (string) $replacement->id],
);
}
#[Test]
public function itMapsNotFoundExceptionToHttpNotFound(): void
{
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$this->expectException(NotFoundHttpException::class);
$processor->process(
new TeacherReplacementResource(),
new Delete(),
['id' => '550e8400-e29b-41d4-a716-446655440088'],
);
}
#[Test]
public function itMapsAlreadyEndedExceptionToBadRequest(): void
{
$replacement = $this->createAndSaveReplacement();
$replacement->terminer(new DateTimeImmutable('2026-03-10'));
$this->replacementRepository->save($replacement);
$processor = $this->createProcessor(granted: true);
$this->setTenant();
$this->expectException(BadRequestHttpException::class);
$processor->process(
new TeacherReplacementResource(),
new Delete(),
['id' => (string) $replacement->id],
);
}
private function createAndSaveReplacement(): TeacherReplacement
{
$replacement = 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: [
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'),
);
$this->replacementRepository->save($replacement);
return $replacement;
}
private function setTenant(): void
{
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_ID),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
}
private function createProcessor(bool $granted): EndTeacherReplacementProcessor
{
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
public function __construct(private readonly bool $granted)
{
}
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
{
return $this->granted;
}
};
$handler = new EndReplacementHandler(
$this->replacementRepository,
$this->clock,
);
return new EndTeacherReplacementProcessor(
$handler,
$this->tenantContext,
$authChecker,
);
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\GetCollection;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\Subject\SubjectStatus;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\GetReplacedClassesForTeacherHandler;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Infrastructure\Api\Provider\ReplacedClassesProvider;
use App\Scolarite\Infrastructure\Api\Resource\ReplacedClassResource;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
final class ReplacedClassesProviderTest extends TestCase
{
private const string TENANT_UUID = '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 const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440040';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440041';
private InMemoryTeacherReplacementRepository $repository;
private InMemoryClassRepository $classRepository;
private InMemorySubjectRepository $subjectRepository;
private TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherReplacementRepository();
$this->classRepository = new InMemoryClassRepository();
$this->subjectRepository = new InMemorySubjectRepository();
$this->tenantContext = new TenantContext();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-15 10:00:00');
}
};
$this->seedClassesAndSubjects();
}
#[Test]
public function itProvidesReplacedClassesForAuthenticatedTeacher(): void
{
$provider = $this->createProvider(self::REPLACEMENT_TEACHER_ID);
$this->setTenant();
$replacement = $this->createReplacement();
$this->repository->save($replacement);
$result = $provider->provide(new GetCollection());
self::assertCount(1, $result);
self::assertContainsOnlyInstancesOf(ReplacedClassResource::class, $result);
self::assertSame((string) $replacement->id, $result[0]->replacementId);
self::assertSame(self::REPLACED_TEACHER_ID, $result[0]->replacedTeacherId);
self::assertSame(self::CLASS_ID, $result[0]->classId);
self::assertSame(self::SUBJECT_ID, $result[0]->subjectId);
}
#[Test]
public function itReturnsEmptyWhenNoReplacements(): void
{
$provider = $this->createProvider(self::REPLACEMENT_TEACHER_ID);
$this->setTenant();
$result = $provider->provide(new GetCollection());
self::assertSame([], $result);
}
#[Test]
public function itThrowsWhenUserNotAuthenticated(): void
{
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn(null);
$handler = new GetReplacedClassesForTeacherHandler(
$this->repository,
$this->classRepository,
$this->subjectRepository,
$this->clock,
);
$provider = new ReplacedClassesProvider($handler, $this->tenantContext, $security);
$this->setTenant();
$this->expectException(UnauthorizedHttpException::class);
$this->expectExceptionMessage('Authentification requise.');
$provider->provide(new GetCollection());
}
#[Test]
public function itThrowsWhenTenantNotSet(): void
{
$provider = $this->createProvider(self::REPLACEMENT_TEACHER_ID);
$this->expectException(UnauthorizedHttpException::class);
$this->expectExceptionMessage('Tenant non défini.');
$provider->provide(new GetCollection());
}
private function setTenant(): void
{
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
}
private function createProvider(string $userId): ReplacedClassesProvider
{
$securityUser = new SecurityUser(
UserId::fromString($userId),
'teacher@test.com',
'hashed',
TenantId::fromString(self::TENANT_UUID),
['ROLE_PROF'],
);
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($securityUser);
$handler = new GetReplacedClassesForTeacherHandler(
$this->repository,
$this->classRepository,
$this->subjectRepository,
$this->clock,
);
return new ReplacedClassesProvider($handler, $this->tenantContext, $security);
}
private function seedClassesAndSubjects(): void
{
$tenantId = TenantId::fromString(self::TENANT_UUID);
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$now = new DateTimeImmutable('2026-01-01');
$class = SchoolClass::reconstitute(
ClassId::fromString(self::CLASS_ID), $tenantId, $schoolId, $academicYearId,
new ClassName('6ème A'), null, null,
ClassStatus::ACTIVE,
null, $now, $now, null,
);
$this->classRepository->save($class);
$subject = Subject::reconstitute(
SubjectId::fromString(self::SUBJECT_ID), $tenantId, $schoolId,
new SubjectName('Mathématiques'), new SubjectCode('MATH'), null,
SubjectStatus::ACTIVE, null, $now, $now, null,
);
$this->subjectRepository->save($subject);
}
private function createReplacement(): TeacherReplacement
{
return TeacherReplacement::designer(
tenantId: TenantId::fromString(self::TENANT_UUID),
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: [
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'),
);
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Delete;
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\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Infrastructure\Api\Provider\TeacherReplacementItemProvider;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AccessDecision;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class TeacherReplacementItemProviderTest extends TestCase
{
private const string TENANT_UUID = '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 TenantContext $tenantContext;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherReplacementRepository();
$this->tenantContext = new TenantContext();
}
#[Test]
public function itProvidesSingleReplacementById(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$replacement = $this->createReplacement();
$this->repository->save($replacement);
$result = $provider->provide(
new Delete(),
['id' => (string) $replacement->id],
);
self::assertInstanceOf(TeacherReplacementResource::class, $result);
self::assertSame((string) $replacement->id, $result->id);
self::assertSame(self::REPLACED_TEACHER_ID, $result->replacedTeacherId);
self::assertSame(self::REPLACEMENT_TEACHER_ID, $result->replacementTeacherId);
self::assertSame('active', $result->status);
}
#[Test]
public function itThrowsNotFoundWhenReplacementDoesNotExist(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$this->expectException(NotFoundHttpException::class);
$this->expectExceptionMessage('Remplacement non trouvé.');
$provider->provide(
new Delete(),
['id' => '550e8400-e29b-41d4-a716-446655440099'],
);
}
#[Test]
public function itRejectsUnauthorizedAccess(): void
{
$provider = $this->createProvider(granted: false);
$this->setTenant();
$this->expectException(AccessDeniedHttpException::class);
$provider->provide(
new Delete(),
['id' => '550e8400-e29b-41d4-a716-446655440099'],
);
}
#[Test]
public function itRejectsRequestWithoutTenant(): void
{
$provider = $this->createProvider(granted: true);
$this->expectException(UnauthorizedHttpException::class);
$provider->provide(
new Delete(),
['id' => '550e8400-e29b-41d4-a716-446655440099'],
);
}
private function setTenant(): void
{
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
}
private function createProvider(bool $granted): TeacherReplacementItemProvider
{
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
public function __construct(private readonly bool $granted)
{
}
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
{
return $this->granted;
}
};
return new TeacherReplacementItemProvider($this->repository, $this->tenantContext, $authChecker);
}
private function createReplacement(): TeacherReplacement
{
return TeacherReplacement::designer(
tenantId: TenantId::fromString(self::TENANT_UUID),
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: [
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'),
);
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\GetCollection;
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\Query\GetActiveReplacements\GetActiveReplacementsHandler;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Infrastructure\Api\Provider\TeacherReplacementsCollectionProvider;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AccessDecision;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class TeacherReplacementsCollectionProviderTest extends TestCase
{
private const string TENANT_UUID = '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 TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherReplacementRepository();
$this->tenantContext = new TenantContext();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-15 10:00:00');
}
};
}
#[Test]
public function itProvidesCollectionOfActiveReplacements(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$replacement = $this->createReplacement();
$this->repository->save($replacement);
$result = $provider->provide(new GetCollection());
self::assertCount(1, $result);
self::assertContainsOnlyInstancesOf(TeacherReplacementResource::class, $result);
self::assertSame((string) $replacement->id, $result[0]->id);
self::assertSame(self::REPLACED_TEACHER_ID, $result[0]->replacedTeacherId);
self::assertSame(self::REPLACEMENT_TEACHER_ID, $result[0]->replacementTeacherId);
self::assertSame('active', $result[0]->status);
}
#[Test]
public function itReturnsEmptyWhenNoActiveReplacements(): void
{
$provider = $this->createProvider(granted: true);
$this->setTenant();
$result = $provider->provide(new GetCollection());
self::assertSame([], $result);
}
#[Test]
public function itRejectsUnauthorizedAccess(): void
{
$provider = $this->createProvider(granted: false);
$this->setTenant();
$this->expectException(AccessDeniedHttpException::class);
$provider->provide(new GetCollection());
}
#[Test]
public function itRejectsRequestWithoutTenant(): void
{
$provider = $this->createProvider(granted: true);
$this->expectException(UnauthorizedHttpException::class);
$provider->provide(new GetCollection());
}
private function setTenant(): void
{
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
subdomain: 'test',
databaseUrl: 'sqlite:///:memory:',
));
}
private function createProvider(bool $granted): TeacherReplacementsCollectionProvider
{
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
public function __construct(private readonly bool $granted)
{
}
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
{
return $this->granted;
}
};
$handler = new GetActiveReplacementsHandler($this->repository, $this->clock);
return new TeacherReplacementsCollectionProvider($handler, $this->tenantContext, $authChecker);
}
private function createReplacement(): TeacherReplacement
{
return TeacherReplacement::designer(
tenantId: TenantId::fromString(self::TENANT_UUID),
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: [
new ClassSubjectPair(
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
),
],
reason: 'Congé maladie',
createdBy: UserId::fromString(self::CREATED_BY_ID),
now: new DateTimeImmutable('2026-02-15 10:00:00'),
);
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Resource;
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\Query\GetActiveReplacements\ReplacementDto;
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\Api\Resource\TeacherReplacementResource;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TeacherReplacementResourceTest 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 fromDomainCreatesCorrectResource(): void
{
$replacement = $this->createReplacement();
$resource = TeacherReplacementResource::fromDomain($replacement);
self::assertSame((string) $replacement->id, $resource->id);
self::assertSame(self::REPLACED_TEACHER_ID, $resource->replacedTeacherId);
self::assertSame(self::REPLACEMENT_TEACHER_ID, $resource->replacementTeacherId);
self::assertSame('2026-03-01', $resource->startDate);
self::assertSame('2026-03-31', $resource->endDate);
self::assertSame('active', $resource->status);
self::assertSame('Congé maladie', $resource->reason);
self::assertEquals($replacement->createdAt, $resource->createdAt);
self::assertNull($resource->endedAt);
}
#[Test]
public function fromDomainMapsClassSubjectPairs(): void
{
$replacement = $this->createReplacement();
$resource = TeacherReplacementResource::fromDomain($replacement);
self::assertCount(1, $resource->classes);
self::assertSame(self::CLASS_ID, $resource->classes[0]['classId']);
self::assertSame(self::SUBJECT_ID, $resource->classes[0]['subjectId']);
}
#[Test]
public function fromDomainPreservesEndedAtWhenTerminated(): void
{
$replacement = $this->createReplacement();
$endedAt = new DateTimeImmutable('2026-03-20 14:00:00');
$replacement->terminer($endedAt);
$resource = TeacherReplacementResource::fromDomain($replacement);
self::assertSame('ended', $resource->status);
self::assertEquals($endedAt, $resource->endedAt);
}
#[Test]
public function fromDtoCreatesCorrectResource(): void
{
$startDate = new DateTimeImmutable('2026-03-01');
$endDate = new DateTimeImmutable('2026-03-31');
$dto = new ReplacementDto(
id: '550e8400-e29b-41d4-a716-446655440050',
replacedTeacherId: self::REPLACED_TEACHER_ID,
replacementTeacherId: self::REPLACEMENT_TEACHER_ID,
startDate: $startDate,
endDate: $endDate,
status: ReplacementStatus::ACTIVE->value,
classes: [
['classId' => self::CLASS_ID, 'subjectId' => self::SUBJECT_ID],
],
reason: 'Formation',
);
$resource = TeacherReplacementResource::fromDto($dto);
self::assertSame('550e8400-e29b-41d4-a716-446655440050', $resource->id);
self::assertSame(self::REPLACED_TEACHER_ID, $resource->replacedTeacherId);
self::assertSame(self::REPLACEMENT_TEACHER_ID, $resource->replacementTeacherId);
self::assertSame('2026-03-01', $resource->startDate);
self::assertSame('2026-03-31', $resource->endDate);
self::assertSame('active', $resource->status);
self::assertSame('Formation', $resource->reason);
self::assertCount(1, $resource->classes);
self::assertSame(self::CLASS_ID, $resource->classes[0]['classId']);
self::assertSame(self::SUBJECT_ID, $resource->classes[0]['subjectId']);
}
#[Test]
public function fromDtoPreservesNullReason(): void
{
$dto = new ReplacementDto(
id: '550e8400-e29b-41d4-a716-446655440050',
replacedTeacherId: self::REPLACED_TEACHER_ID,
replacementTeacherId: self::REPLACEMENT_TEACHER_ID,
startDate: new DateTimeImmutable('2026-03-01'),
endDate: new DateTimeImmutable('2026-03-31'),
status: ReplacementStatus::ACTIVE->value,
classes: [],
reason: null,
);
$resource = TeacherReplacementResource::fromDto($dto);
self::assertNull($resource->reason);
}
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: [
new ClassSubjectPair(
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
),
],
reason: 'Congé maladie',
createdBy: UserId::fromString(self::CREATED_BY_ID),
now: new DateTimeImmutable('2026-02-15 10:00:00'),
);
}
}