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

@@ -165,6 +165,10 @@ services:
App\Administration\Application\Port\TeacherAssignmentChecker: App\Administration\Application\Port\TeacherAssignmentChecker:
alias: App\Administration\Infrastructure\Service\RepositoryTeacherAssignmentChecker alias: App\Administration\Infrastructure\Service\RepositoryTeacherAssignmentChecker
# Teacher Replacement Repository (Story 2.9 - Remplaçants temporaires)
App\Scolarite\Domain\Repository\TeacherReplacementRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineTeacherReplacementRepository
# Student Guardian Repository (Story 2.7 - Liaison parents-enfants) # Student Guardian Repository (Story 2.7 - Liaison parents-enfants)
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository: App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
arguments: arguments:

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Creates the teacher_replacements and replacement_classes tables for Story 2.9.
*
* Models temporary teacher replacement: an admin designates a replacement teacher
* with start/end dates for specific class/subject pairs.
*/
final class Version20260215100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create teacher_replacements and replacement_classes tables (Story 2.9)';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS teacher_replacements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
replaced_teacher_id UUID NOT NULL,
replacement_teacher_id UUID NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
reason TEXT,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT valid_dates CHECK (end_date >= start_date),
CONSTRAINT different_teachers CHECK (replaced_teacher_id != replacement_teacher_id)
)
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS replacement_classes (
replacement_id UUID NOT NULL REFERENCES teacher_replacements(id) ON DELETE CASCADE,
class_id UUID NOT NULL,
subject_id UUID NOT NULL,
PRIMARY KEY (replacement_id, class_id, subject_id)
)
SQL);
$this->addSql('CREATE INDEX idx_replacements_tenant ON teacher_replacements(tenant_id)');
$this->addSql('CREATE INDEX idx_replacements_dates ON teacher_replacements(start_date, end_date)');
$this->addSql('CREATE INDEX idx_replacements_status ON teacher_replacements(status)');
$this->addSql('CREATE INDEX idx_replacements_replaced_teacher ON teacher_replacements(replaced_teacher_id)');
$this->addSql('CREATE INDEX idx_replacements_replacement_teacher ON teacher_replacements(replacement_teacher_id)');
$this->addSql('CREATE INDEX idx_replacements_tenant_status ON teacher_replacements(tenant_id, status)');
$this->addSql('CREATE INDEX idx_replacements_status_end_date ON teacher_replacements(status, end_date)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS replacement_classes');
$this->addSql('DROP TABLE IF EXISTS teacher_replacements');
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DesignateReplacement;
final readonly class DesignateReplacementCommand
{
/**
* @param array<array{classId: string, subjectId: string}> $classes
*/
public function __construct(
public string $tenantId,
public string $replacedTeacherId,
public string $replacementTeacherId,
public string $startDate,
public string $endDate,
public array $classes,
public ?string $reason,
public string $createdBy,
) {
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DesignateReplacement;
use App\Administration\Domain\Exception\TenantMismatchException;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Scolarite\Domain\Exception\UtilisateurNonEnseignantException;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class DesignateReplacementHandler
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private UserRepository $userRepository,
private Clock $clock,
) {
}
public function __invoke(DesignateReplacementCommand $command): TeacherReplacement
{
$tenantId = TenantId::fromString($command->tenantId);
$replacedTeacherId = UserId::fromString($command->replacedTeacherId);
$replacementTeacherId = UserId::fromString($command->replacementTeacherId);
// Valider l'existence, le tenant et le rôle des enseignants
$replacedTeacher = $this->userRepository->get($replacedTeacherId);
if (!$replacedTeacher->tenantId->equals($tenantId)) {
throw TenantMismatchException::pourUtilisateur($replacedTeacherId, $tenantId);
}
if (!$replacedTeacher->aLeRole(Role::PROF)) {
throw UtilisateurNonEnseignantException::pourUtilisateur($replacedTeacherId);
}
$replacementTeacher = $this->userRepository->get($replacementTeacherId);
if (!$replacementTeacher->tenantId->equals($tenantId)) {
throw TenantMismatchException::pourUtilisateur($replacementTeacherId, $tenantId);
}
if (!$replacementTeacher->aLeRole(Role::PROF)) {
throw UtilisateurNonEnseignantException::pourUtilisateur($replacementTeacherId);
}
$classes = array_map(
static fn (array $pair) => new ClassSubjectPair(
ClassId::fromString($pair['classId']),
SubjectId::fromString($pair['subjectId']),
),
$command->classes,
);
$replacement = TeacherReplacement::designer(
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: new DateTimeImmutable($command->startDate),
endDate: new DateTimeImmutable($command->endDate),
classes: $classes,
reason: $command->reason,
createdBy: UserId::fromString($command->createdBy),
now: $this->clock->now(),
);
$this->replacementRepository->save($replacement);
return $replacement;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\EndReplacement;
final readonly class EndReplacementCommand
{
public function __construct(
public string $replacementId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\EndReplacement;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class EndReplacementHandler
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private Clock $clock,
) {
}
public function __invoke(EndReplacementCommand $command): void
{
$replacement = $this->replacementRepository->get(
TeacherReplacementId::fromString($command->replacementId),
TenantId::fromString($command->tenantId),
);
$replacement->terminer($this->clock->now());
$this->replacementRepository->save($replacement);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetActiveReplacements;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetActiveReplacementsHandler
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private Clock $clock,
) {
}
/** @return array<ReplacementDto> */
public function __invoke(GetActiveReplacementsQuery $query): array
{
$replacements = $this->replacementRepository->findActiveByTenant(
TenantId::fromString($query->tenantId),
$this->clock->now(),
);
return array_map(
static fn ($r) => ReplacementDto::fromDomain($r),
$replacements,
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetActiveReplacements;
final readonly class GetActiveReplacementsQuery
{
public function __construct(
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetActiveReplacements;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use DateTimeImmutable;
final readonly class ReplacementDto
{
/** @param array<array{classId: string, subjectId: string}> $classes */
public function __construct(
public string $id,
public string $replacedTeacherId,
public string $replacementTeacherId,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
public string $status,
public array $classes,
public ?string $reason,
) {
}
public static function fromDomain(TeacherReplacement $replacement): self
{
return new self(
id: (string) $replacement->id,
replacedTeacherId: (string) $replacement->replacedTeacherId,
replacementTeacherId: (string) $replacement->replacementTeacherId,
startDate: $replacement->startDate,
endDate: $replacement->endDate,
status: $replacement->status->value,
classes: array_map(
static fn ($pair) => [
'classId' => (string) $pair->classId,
'subjectId' => (string) $pair->subjectId,
],
$replacement->classes,
),
reason: $replacement->reason,
);
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetReplacedClassesForTeacher;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetReplacedClassesForTeacherHandler
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private Clock $clock,
) {
}
/** @return array<ReplacedClassDto> */
public function __invoke(GetReplacedClassesForTeacherQuery $query): array
{
$tenantId = TenantId::fromString($query->tenantId);
$replacements = $this->replacementRepository->findActiveByReplacementTeacher(
UserId::fromString($query->replacementTeacherId),
$tenantId,
$this->clock->now(),
);
if ($replacements === []) {
return [];
}
$classNames = [];
foreach ($this->classRepository->findAllActiveByTenant($tenantId) as $class) {
$classNames[(string) $class->id] = (string) $class->name;
}
$subjectNames = [];
foreach ($this->subjectRepository->findAllActiveByTenant($tenantId) as $subject) {
$subjectNames[(string) $subject->id] = (string) $subject->name;
}
$result = [];
foreach ($replacements as $replacement) {
foreach ($replacement->classes as $pair) {
$classId = (string) $pair->classId;
$subjectId = (string) $pair->subjectId;
$result[] = new ReplacedClassDto(
replacementId: (string) $replacement->id,
replacedTeacherId: (string) $replacement->replacedTeacherId,
classId: $classId,
subjectId: $subjectId,
className: $classNames[$classId] ?? $classId,
subjectName: $subjectNames[$subjectId] ?? $subjectId,
startDate: $replacement->startDate,
endDate: $replacement->endDate,
);
}
}
return $result;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetReplacedClassesForTeacher;
final readonly class GetReplacedClassesForTeacherQuery
{
public function __construct(
public string $replacementTeacherId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetReplacedClassesForTeacher;
use DateTimeImmutable;
final readonly class ReplacedClassDto
{
public function __construct(
public string $replacementId,
public string $replacedTeacherId,
public string $classId,
public string $subjectId,
public string $className,
public string $subjectName,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
) {
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class RemplacementDesigne implements DomainEvent
{
public function __construct(
public TeacherReplacementId $replacementId,
public UserId $replacedTeacherId,
public UserId $replacementTeacherId,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->replacementId->value;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class RemplacementTermine implements DomainEvent
{
public function __construct(
public TeacherReplacementId $replacementId,
public UserId $replacedTeacherId,
public UserId $replacementTeacherId,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->replacementId->value;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DateTimeImmutable;
use DomainException;
use function sprintf;
final class DatesRemplacementInvalidesException extends DomainException
{
public static function finAvantDebut(DateTimeImmutable $startDate, DateTimeImmutable $endDate): self
{
return new self(sprintf(
'La date de début (%s) doit être antérieure à la date de fin (%s).',
$startDate->format('Y-m-d'),
$endDate->format('Y-m-d'),
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use DomainException;
use function sprintf;
final class RemplacementDejaTermineException extends DomainException
{
public static function withId(TeacherReplacementId $id): self
{
return new self(sprintf(
'Le remplacement "%s" est déjà terminé.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use DomainException;
use function sprintf;
final class RemplacementNotFoundException extends DomainException
{
public static function withId(TeacherReplacementId $id): self
{
return new self(sprintf(
'Le remplacement avec l\'ID "%s" n\'a pas été trouvé.',
$id,
));
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
final class RemplacementSameTeacherException extends DomainException
{
public static function create(): self
{
return new self('L\'enseignant remplacé et le remplaçant ne peuvent pas être la même personne.');
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class UtilisateurNonEnseignantException extends DomainException
{
public static function pourUtilisateur(UserId $userId): self
{
return new self(sprintf(
'L\'utilisateur « %s » n\'a pas le rôle enseignant.',
$userId,
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
final readonly class ClassSubjectPair
{
public function __construct(
public ClassId $classId,
public SubjectId $subjectId,
) {
}
public function equals(self $other): bool
{
return $this->classId->equals($other->classId)
&& $this->subjectId->equals($other->subjectId);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
enum ReplacementStatus: string
{
case ACTIVE = 'active';
case ENDED = 'ended';
public function estActive(): bool
{
return $this === self::ACTIVE;
}
public function label(): string
{
return match ($this) {
self::ACTIVE => 'Actif',
self::ENDED => 'Terminé',
};
}
}

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
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\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Aggregate Root représentant le remplacement temporaire d'un enseignant.
*
* Un administrateur désigne un remplaçant pour un enseignant absent, avec des dates
* de début/fin et un périmètre de classes/matières. Le remplaçant peut saisir de
* nouvelles notes et lire les notes passées (lecture seule).
*
* @see FR9: Désigner remplaçant temporaire
*/
final class TeacherReplacement extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
/** @param array<ClassSubjectPair> $classes */
private function __construct(
public private(set) TeacherReplacementId $id,
public private(set) TenantId $tenantId,
public private(set) UserId $replacedTeacherId,
public private(set) UserId $replacementTeacherId,
public private(set) DateTimeImmutable $startDate,
public private(set) DateTimeImmutable $endDate,
public private(set) ReplacementStatus $status,
public private(set) array $classes,
public private(set) ?string $reason,
public private(set) UserId $createdBy,
public private(set) DateTimeImmutable $createdAt,
public private(set) ?DateTimeImmutable $endedAt,
) {
$this->updatedAt = $createdAt;
}
/** @param array<ClassSubjectPair> $classes */
public static function designer(
TenantId $tenantId,
UserId $replacedTeacherId,
UserId $replacementTeacherId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
array $classes,
?string $reason,
UserId $createdBy,
DateTimeImmutable $now,
): self {
if ($replacedTeacherId->equals($replacementTeacherId)) {
throw RemplacementSameTeacherException::create();
}
if ($endDate < $startDate) {
throw DatesRemplacementInvalidesException::finAvantDebut($startDate, $endDate);
}
if ($classes === []) {
throw new InvalidArgumentException('Au moins une paire classe/matière est requise.');
}
$replacement = new self(
id: TeacherReplacementId::generate(),
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
status: ReplacementStatus::ACTIVE,
classes: $classes,
reason: $reason,
createdBy: $createdBy,
createdAt: $now,
endedAt: null,
);
$replacement->recordEvent(new RemplacementDesigne(
replacementId: $replacement->id,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
occurredOn: $now,
));
return $replacement;
}
public function isActive(DateTimeImmutable $now): bool
{
return $this->status === ReplacementStatus::ACTIVE
&& $now >= $this->startDate
&& $now <= $this->endDate;
}
public function isExpired(DateTimeImmutable $now): bool
{
return $this->status === ReplacementStatus::ACTIVE
&& $now > $this->endDate;
}
public function terminer(DateTimeImmutable $at): void
{
if ($this->status === ReplacementStatus::ENDED) {
throw RemplacementDejaTermineException::withId($this->id);
}
$this->status = ReplacementStatus::ENDED;
$this->endedAt = $at;
$this->updatedAt = $at;
$this->recordEvent(new RemplacementTermine(
replacementId: $this->id,
replacedTeacherId: $this->replacedTeacherId,
replacementTeacherId: $this->replacementTeacherId,
startDate: $this->startDate,
endDate: $this->endDate,
occurredOn: $at,
));
}
public function couvreClasseMatiere(ClassSubjectPair $pair): bool
{
foreach ($this->classes as $classSubjectPair) {
if ($classSubjectPair->equals($pair)) {
return true;
}
}
return false;
}
/**
* Reconstitue un TeacherReplacement depuis le stockage.
*
* @internal Pour usage Infrastructure uniquement
*
* @param array<ClassSubjectPair> $classes
*/
public static function reconstitute(
TeacherReplacementId $id,
TenantId $tenantId,
UserId $replacedTeacherId,
UserId $replacementTeacherId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
ReplacementStatus $status,
array $classes,
?string $reason,
UserId $createdBy,
DateTimeImmutable $createdAt,
?DateTimeImmutable $endedAt,
DateTimeImmutable $updatedAt,
): self {
$replacement = new self(
id: $id,
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
status: $status,
classes: $classes,
reason: $reason,
createdBy: $createdBy,
createdAt: $createdAt,
endedAt: $endedAt,
);
$replacement->updatedAt = $updatedAt;
return $replacement;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
use App\Shared\Domain\EntityId;
final readonly class TeacherReplacementId extends EntityId
{
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
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\Exception\RemplacementNotFoundException;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
interface TeacherReplacementRepository
{
public function save(TeacherReplacement $replacement): void;
/** @throws RemplacementNotFoundException */
public function get(TeacherReplacementId $id, TenantId $tenantId): TeacherReplacement;
public function findById(TeacherReplacementId $id, TenantId $tenantId): ?TeacherReplacement;
public function findActiveReplacement(
UserId $replacementTeacherId,
ClassId $classId,
SubjectId $subjectId,
DateTimeImmutable $at,
TenantId $tenantId,
): ?TeacherReplacement;
/** @return array<TeacherReplacement> */
public function findActiveByTenant(TenantId $tenantId, DateTimeImmutable $at): array;
/** @return array<TeacherReplacement> */
public function findActiveByReplacementTeacher(
UserId $replacementTeacherId,
TenantId $tenantId,
DateTimeImmutable $at,
): array;
/** @return array<TeacherReplacement> */
public function findExpired(DateTimeImmutable $at): array;
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Domain\Exception\TenantMismatchException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\DesignateReplacement\DesignateReplacementCommand;
use App\Scolarite\Application\Command\DesignateReplacement\DesignateReplacementHandler;
use App\Scolarite\Domain\Exception\DatesRemplacementInvalidesException;
use App\Scolarite\Domain\Exception\RemplacementSameTeacherException;
use App\Scolarite\Domain\Exception\UtilisateurNonEnseignantException;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
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\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<TeacherReplacementResource, TeacherReplacementResource>
*/
final readonly class CreateTeacherReplacementProcessor implements ProcessorInterface
{
public function __construct(
private DesignateReplacementHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private Security $security,
) {
}
/**
* @param TeacherReplacementResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TeacherReplacementResource
{
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::CREATE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer un remplacement.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
try {
$command = new DesignateReplacementCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
replacedTeacherId: $data->replacedTeacherId ?? '',
replacementTeacherId: $data->replacementTeacherId ?? '',
startDate: $data->startDate ?? '',
endDate: $data->endDate ?? '',
classes: $data->classes ?? [],
reason: $data->reason,
createdBy: $user->userId(),
);
$replacement = ($this->handler)($command);
foreach ($replacement->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return TeacherReplacementResource::fromDomain($replacement);
} catch (RemplacementSameTeacherException|DatesRemplacementInvalidesException|TenantMismatchException|UtilisateurNonEnseignantException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (UserNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Scolarite\Application\Command\EndReplacement\EndReplacementCommand;
use App\Scolarite\Application\Command\EndReplacement\EndReplacementHandler;
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
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\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<TeacherReplacementResource, null>
*/
final readonly class EndTeacherReplacementProcessor implements ProcessorInterface
{
public function __construct(
private EndReplacementHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::DELETE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à terminer un remplacement.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $id */
$id = $uriVariables['id'];
try {
($this->handler)(new EndReplacementCommand(
replacementId: $id,
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
));
} catch (RemplacementNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (RemplacementDejaTermineException $e) {
throw new BadRequestHttpException($e->getMessage());
}
return null;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\GetReplacedClassesForTeacherHandler;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\GetReplacedClassesForTeacherQuery;
use App\Scolarite\Infrastructure\Api\Resource\ReplacedClassResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* Fournit les classes du remplaçant connecté (GET /api/me/replaced-classes).
*
* @implements ProviderInterface<ReplacedClassResource>
*/
final readonly class ReplacedClassesProvider implements ProviderInterface
{
public function __construct(
private GetReplacedClassesForTeacherHandler $handler,
private TenantContext $tenantContext,
private Security $security,
) {
}
/** @return array<ReplacedClassResource> */
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$query = new GetReplacedClassesForTeacherQuery(
replacementTeacherId: $user->userId(),
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
);
return array_map(
ReplacedClassResource::fromDto(...),
($this->handler)($query),
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<TeacherReplacementResource>
*/
final readonly class TeacherReplacementItemProvider implements ProviderInterface
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TeacherReplacementResource
{
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::DELETE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à terminer les remplacements.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $id */
$id = $uriVariables['id'];
$tenantId = TenantId::fromString((string) $this->tenantContext->getCurrentTenantId());
$replacement = $this->replacementRepository->findById(
TeacherReplacementId::fromString($id),
$tenantId,
);
if ($replacement === null) {
throw new NotFoundHttpException('Remplacement non trouvé.');
}
return TeacherReplacementResource::fromDomain($replacement);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Scolarite\Application\Query\GetActiveReplacements\GetActiveReplacementsHandler;
use App\Scolarite\Application\Query\GetActiveReplacements\GetActiveReplacementsQuery;
use App\Scolarite\Application\Query\GetActiveReplacements\ReplacementDto;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<TeacherReplacementResource>
*/
final readonly class TeacherReplacementsCollectionProvider implements ProviderInterface
{
public function __construct(
private GetActiveReplacementsHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/** @return array<TeacherReplacementResource> */
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les remplacements.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$query = new GetActiveReplacementsQuery(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
);
$dtos = ($this->handler)($query);
return array_map(
static fn (ReplacementDto $dto) => TeacherReplacementResource::fromDto($dto),
$dtos,
);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\ReplacedClassDto;
use App\Scolarite\Infrastructure\Api\Provider\ReplacedClassesProvider;
/**
* Classes/matières pour lesquelles l'enseignant connecté est remplaçant.
*/
#[ApiResource(
shortName: 'ReplacedClass',
operations: [
new GetCollection(
uriTemplate: '/me/replaced-classes',
provider: ReplacedClassesProvider::class,
name: 'get_replaced_classes',
),
],
)]
final class ReplacedClassResource
{
#[ApiProperty(identifier: true)]
public string $id;
public string $replacementId;
public string $replacedTeacherId;
public string $classId;
public string $subjectId;
public string $className;
public string $subjectName;
public string $startDate;
public string $endDate;
public static function fromDto(ReplacedClassDto $dto): self
{
$resource = new self();
$resource->id = $dto->replacementId . '_' . $dto->classId . '_' . $dto->subjectId;
$resource->replacementId = $dto->replacementId;
$resource->replacedTeacherId = $dto->replacedTeacherId;
$resource->classId = $dto->classId;
$resource->subjectId = $dto->subjectId;
$resource->className = $dto->className;
$resource->subjectName = $dto->subjectName;
$resource->startDate = $dto->startDate->format('Y-m-d');
$resource->endDate = $dto->endDate->format('Y-m-d');
return $resource;
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Scolarite\Application\Query\GetActiveReplacements\ReplacementDto;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Infrastructure\Api\Processor\CreateTeacherReplacementProcessor;
use App\Scolarite\Infrastructure\Api\Processor\EndTeacherReplacementProcessor;
use App\Scolarite\Infrastructure\Api\Provider\TeacherReplacementItemProvider;
use App\Scolarite\Infrastructure\Api\Provider\TeacherReplacementsCollectionProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la gestion des remplacements enseignants.
*
* @see Story 2.9 - Désignation Remplaçants Temporaires
* @see FR9 - Désigner remplaçant temporaire
*/
#[ApiResource(
shortName: 'TeacherReplacement',
operations: [
new GetCollection(
uriTemplate: '/teacher-replacements',
provider: TeacherReplacementsCollectionProvider::class,
name: 'get_teacher_replacements',
),
new Post(
uriTemplate: '/teacher-replacements',
processor: CreateTeacherReplacementProcessor::class,
validationContext: ['groups' => ['Default', 'create']],
name: 'create_teacher_replacement',
),
new Delete(
uriTemplate: '/teacher-replacements/{id}',
provider: TeacherReplacementItemProvider::class,
processor: EndTeacherReplacementProcessor::class,
name: 'end_teacher_replacement',
),
],
)]
final class TeacherReplacementResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
#[Assert\NotBlank(message: 'L\'identifiant de l\'enseignant remplacé est requis.', groups: ['create'])]
public ?string $replacedTeacherId = null;
#[Assert\NotBlank(message: 'L\'identifiant du remplaçant est requis.', groups: ['create'])]
public ?string $replacementTeacherId = null;
#[Assert\NotBlank(message: 'La date de début est requise.', groups: ['create'])]
public ?string $startDate = null;
#[Assert\NotBlank(message: 'La date de fin est requise.', groups: ['create'])]
public ?string $endDate = null;
/** @var array<array{classId: string, subjectId: string}>|null */
#[Assert\NotBlank(message: 'Au moins une classe/matière est requise.', groups: ['create'])]
#[Assert\All(
constraints: [
new Assert\Collection(
fields: [
'classId' => [
new Assert\NotBlank(message: 'L\'identifiant de la classe est requis.', groups: ['create']),
new Assert\Uuid(message: 'L\'identifiant de la classe doit être un UUID valide.', groups: ['create']),
],
'subjectId' => [
new Assert\NotBlank(message: 'L\'identifiant de la matière est requis.', groups: ['create']),
new Assert\Uuid(message: 'L\'identifiant de la matière doit être un UUID valide.', groups: ['create']),
],
],
allowExtraFields: false,
allowMissingFields: false,
groups: ['create'],
),
],
groups: ['create'],
)]
public ?array $classes = null;
public ?string $reason = null;
public ?string $status = null;
public ?DateTimeImmutable $createdAt = null;
public ?DateTimeImmutable $endedAt = null;
public static function fromDomain(TeacherReplacement $replacement): self
{
$resource = new self();
$resource->id = (string) $replacement->id;
$resource->replacedTeacherId = (string) $replacement->replacedTeacherId;
$resource->replacementTeacherId = (string) $replacement->replacementTeacherId;
$resource->startDate = $replacement->startDate->format('Y-m-d');
$resource->endDate = $replacement->endDate->format('Y-m-d');
$resource->classes = array_map(
static fn ($pair) => [
'classId' => (string) $pair->classId,
'subjectId' => (string) $pair->subjectId,
],
$replacement->classes,
);
$resource->reason = $replacement->reason;
$resource->status = $replacement->status->value;
$resource->createdAt = $replacement->createdAt;
$resource->endedAt = $replacement->endedAt;
return $resource;
}
public static function fromDto(ReplacementDto $dto): self
{
$resource = new self();
$resource->id = $dto->id;
$resource->replacedTeacherId = $dto->replacedTeacherId;
$resource->replacementTeacherId = $dto->replacementTeacherId;
$resource->startDate = $dto->startDate->format('Y-m-d');
$resource->endDate = $dto->endDate->format('Y-m-d');
$resource->classes = $dto->classes;
$resource->reason = $dto->reason;
$resource->status = $dto->status;
return $resource;
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Console;
use App\Scolarite\Application\Command\EndReplacement\EndReplacementCommand;
use App\Scolarite\Application\Command\EndReplacement\EndReplacementHandler;
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use function count;
use Override;
use Psr\Log\LoggerInterface;
use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
/**
* Termine automatiquement les remplacements dont la date de fin est dépassée.
*
* CRON: 0 0 * * * php bin/console app:end-expired-replacements
*
* @see Story 2.9 - AC3: Fin automatique remplacement
*/
#[AsCommand(
name: 'app:end-expired-replacements',
description: 'Termine les remplacements dont la date de fin est dépassée',
)]
final class EndExpiredReplacementsCommand extends Command
{
public function __construct(
private readonly TeacherReplacementRepository $replacementRepository,
private readonly EndReplacementHandler $endReplacementHandler,
private readonly Clock $clock,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Fin automatique des remplacements expirés');
$expiredReplacements = $this->replacementRepository->findExpired($this->clock->now());
if ($expiredReplacements === []) {
$io->success('Aucun remplacement expiré à traiter.');
return Command::SUCCESS;
}
$io->info(sprintf('%d remplacement(s) expiré(s) trouvé(s)', count($expiredReplacements)));
$endedCount = 0;
foreach ($expiredReplacements as $replacement) {
try {
($this->endReplacementHandler)(new EndReplacementCommand(
replacementId: (string) $replacement->id,
tenantId: (string) $replacement->tenantId,
));
$io->writeln(sprintf(
' ✅ Remplacement %s terminé (remplaçant: %s)',
$replacement->id,
$replacement->replacementTeacherId,
));
$this->logger->info('Remplacement terminé automatiquement', [
'replacement_id' => (string) $replacement->id,
'tenant_id' => (string) $replacement->tenantId,
'replaced_teacher_id' => (string) $replacement->replacedTeacherId,
'replacement_teacher_id' => (string) $replacement->replacementTeacherId,
]);
++$endedCount;
} catch (RemplacementDejaTermineException $e) {
$io->warning(sprintf(
' ⚠ Remplacement %s déjà terminé, ignoré',
$replacement->id,
));
$this->logger->info('Remplacement déjà terminé, ignoré lors du traitement automatique', [
'replacement_id' => (string) $replacement->id,
]);
} catch (Throwable $e) {
$io->error(sprintf(
'Erreur pour le remplacement %s : %s',
$replacement->id,
$e->getMessage(),
));
$this->logger->error('Erreur lors de la terminaison automatique du remplacement', [
'replacement_id' => (string) $replacement->id,
'error' => $e->getMessage(),
]);
}
}
$io->success(sprintf('%d remplacement(s) terminé(s) avec succès.', $endedCount));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
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\Exception\RemplacementNotFoundException;
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\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineTeacherReplacementRepository implements TeacherReplacementRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(TeacherReplacement $replacement): void
{
$this->connection->executeStatement(
'INSERT INTO teacher_replacements (id, tenant_id, replaced_teacher_id, replacement_teacher_id, start_date, end_date, status, reason, created_by, created_at, ended_at, updated_at)
VALUES (:id, :tenant_id, :replaced_teacher_id, :replacement_teacher_id, :start_date, :end_date, :status, :reason, :created_by, :created_at, :ended_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
ended_at = EXCLUDED.ended_at,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $replacement->id,
'tenant_id' => (string) $replacement->tenantId,
'replaced_teacher_id' => (string) $replacement->replacedTeacherId,
'replacement_teacher_id' => (string) $replacement->replacementTeacherId,
'start_date' => $replacement->startDate->format('Y-m-d'),
'end_date' => $replacement->endDate->format('Y-m-d'),
'status' => $replacement->status->value,
'reason' => $replacement->reason,
'created_by' => (string) $replacement->createdBy,
'created_at' => $replacement->createdAt->format(DateTimeImmutable::ATOM),
'ended_at' => $replacement->endedAt?->format(DateTimeImmutable::ATOM),
'updated_at' => $replacement->updatedAt->format(DateTimeImmutable::ATOM),
],
);
// Upsert replacement_classes
$this->connection->executeStatement(
'DELETE FROM replacement_classes WHERE replacement_id = :replacement_id',
['replacement_id' => (string) $replacement->id],
);
foreach ($replacement->classes as $pair) {
$this->connection->executeStatement(
'INSERT INTO replacement_classes (replacement_id, class_id, subject_id) VALUES (:replacement_id, :class_id, :subject_id)',
[
'replacement_id' => (string) $replacement->id,
'class_id' => (string) $pair->classId,
'subject_id' => (string) $pair->subjectId,
],
);
}
}
#[Override]
public function get(TeacherReplacementId $id, TenantId $tenantId): TeacherReplacement
{
$replacement = $this->findById($id, $tenantId);
if ($replacement === null) {
throw RemplacementNotFoundException::withId($id);
}
return $replacement;
}
#[Override]
public function findById(TeacherReplacementId $id, TenantId $tenantId): ?TeacherReplacement
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM teacher_replacements WHERE id = :id AND tenant_id = :tenant_id',
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findActiveReplacement(
UserId $replacementTeacherId,
ClassId $classId,
SubjectId $subjectId,
DateTimeImmutable $at,
TenantId $tenantId,
): ?TeacherReplacement {
$row = $this->connection->fetchAssociative(
'SELECT tr.* FROM teacher_replacements tr
JOIN replacement_classes rc ON rc.replacement_id = tr.id
WHERE tr.tenant_id = :tenant_id
AND tr.replacement_teacher_id = :replacement_teacher_id
AND rc.class_id = :class_id
AND rc.subject_id = :subject_id
AND tr.status = :status
AND tr.start_date <= :at
AND tr.end_date >= :at',
[
'tenant_id' => (string) $tenantId,
'replacement_teacher_id' => (string) $replacementTeacherId,
'class_id' => (string) $classId,
'subject_id' => (string) $subjectId,
'status' => ReplacementStatus::ACTIVE->value,
'at' => $at->format('Y-m-d'),
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findActiveByTenant(TenantId $tenantId, DateTimeImmutable $at): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_replacements
WHERE tenant_id = :tenant_id
AND status = :status
AND start_date <= :at
AND end_date >= :at
ORDER BY start_date ASC',
[
'tenant_id' => (string) $tenantId,
'status' => ReplacementStatus::ACTIVE->value,
'at' => $at->format('Y-m-d'),
],
);
return $this->hydrateMany($rows);
}
#[Override]
public function findActiveByReplacementTeacher(
UserId $replacementTeacherId,
TenantId $tenantId,
DateTimeImmutable $at,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_replacements
WHERE replacement_teacher_id = :replacement_teacher_id
AND tenant_id = :tenant_id
AND status = :status
AND start_date <= :at
AND end_date >= :at
ORDER BY created_at ASC',
[
'replacement_teacher_id' => (string) $replacementTeacherId,
'tenant_id' => (string) $tenantId,
'status' => ReplacementStatus::ACTIVE->value,
'at' => $at->format('Y-m-d'),
],
);
return $this->hydrateMany($rows);
}
#[Override]
public function findExpired(DateTimeImmutable $at): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_replacements
WHERE status = :status
AND end_date < :at
ORDER BY end_date ASC',
[
'status' => ReplacementStatus::ACTIVE->value,
'at' => $at->format('Y-m-d'),
],
);
return $this->hydrateMany($rows);
}
/** @return array<ClassSubjectPair> */
private function loadClasses(string $replacementId): array
{
return $this->loadClassesBatch([$replacementId])[$replacementId] ?? [];
}
/**
* @param list<string> $replacementIds
*
* @return array<string, list<ClassSubjectPair>>
*/
private function loadClassesBatch(array $replacementIds): array
{
if ($replacementIds === []) {
return [];
}
$placeholders = implode(', ', array_fill(0, count($replacementIds), '?'));
$rows = $this->connection->fetchAllAssociative(
"SELECT replacement_id, class_id, subject_id FROM replacement_classes WHERE replacement_id IN ($placeholders)",
array_values($replacementIds),
);
$grouped = [];
foreach ($rows as $row) {
/** @var string $replacementId */
$replacementId = $row['replacement_id'];
/** @var string $classId */
$classId = $row['class_id'];
/** @var string $subjectId */
$subjectId = $row['subject_id'];
$grouped[$replacementId][] = new ClassSubjectPair(
ClassId::fromString($classId),
SubjectId::fromString($subjectId),
);
}
return $grouped;
}
/**
* @param list<array<string, mixed>> $rows
*
* @return list<TeacherReplacement>
*/
private function hydrateMany(array $rows): array
{
if ($rows === []) {
return [];
}
$ids = array_map(static function (array $row): string {
/** @var string $id */
$id = $row['id'];
return $id;
}, $rows);
$classesByReplacement = $this->loadClassesBatch($ids);
return array_map(
function (array $row) use ($classesByReplacement) {
/** @var string $id */
$id = $row['id'];
return $this->hydrate($row, $classesByReplacement[$id] ?? []);
},
$rows,
);
}
/**
* @param array<string, mixed> $row
* @param array<ClassSubjectPair>|null $classes Pre-loaded classes (null = load individually)
*/
private function hydrate(array $row, ?array $classes = null): TeacherReplacement
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $replacedTeacherId */
$replacedTeacherId = $row['replaced_teacher_id'];
/** @var string $replacementTeacherId */
$replacementTeacherId = $row['replacement_teacher_id'];
/** @var string $startDate */
$startDate = $row['start_date'];
/** @var string $endDate */
$endDate = $row['end_date'];
/** @var string $status */
$status = $row['status'];
/** @var string|null $reason */
$reason = $row['reason'];
/** @var string $createdBy */
$createdBy = $row['created_by'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string|null $endedAt */
$endedAt = $row['ended_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return TeacherReplacement::reconstitute(
id: TeacherReplacementId::fromString($id),
tenantId: TenantId::fromString($tenantId),
replacedTeacherId: UserId::fromString($replacedTeacherId),
replacementTeacherId: UserId::fromString($replacementTeacherId),
startDate: new DateTimeImmutable($startDate),
endDate: new DateTimeImmutable($endDate),
status: ReplacementStatus::from($status),
classes: $classes ?? $this->loadClasses($id),
reason: $reason,
createdBy: UserId::fromString($createdBy),
createdAt: new DateTimeImmutable($createdAt),
endedAt: $endedAt !== null ? new DateTimeImmutable($endedAt) : null,
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
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\Exception\RemplacementNotFoundException;
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\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
final class InMemoryTeacherReplacementRepository implements TeacherReplacementRepository
{
/** @var array<string, TeacherReplacement> */
private array $byId = [];
#[Override]
public function save(TeacherReplacement $replacement): void
{
$this->byId[(string) $replacement->id] = $replacement;
}
#[Override]
public function get(TeacherReplacementId $id, TenantId $tenantId): TeacherReplacement
{
$replacement = $this->findById($id, $tenantId);
if ($replacement === null) {
throw RemplacementNotFoundException::withId($id);
}
return $replacement;
}
#[Override]
public function findById(TeacherReplacementId $id, TenantId $tenantId): ?TeacherReplacement
{
$replacement = $this->byId[(string) $id] ?? null;
if ($replacement !== null && !$replacement->tenantId->equals($tenantId)) {
return null;
}
return $replacement;
}
#[Override]
public function findActiveReplacement(
UserId $replacementTeacherId,
ClassId $classId,
SubjectId $subjectId,
DateTimeImmutable $at,
TenantId $tenantId,
): ?TeacherReplacement {
$pair = new ClassSubjectPair($classId, $subjectId);
foreach ($this->byId as $replacement) {
if ($replacement->tenantId->equals($tenantId)
&& $replacement->replacementTeacherId->equals($replacementTeacherId)
&& $replacement->isActive($at)
&& $replacement->couvreClasseMatiere($pair)
) {
return $replacement;
}
}
return null;
}
#[Override]
public function findActiveByTenant(TenantId $tenantId, DateTimeImmutable $at): array
{
return array_values(array_filter(
$this->byId,
static fn (TeacherReplacement $r) => $r->tenantId->equals($tenantId)
&& $r->isActive($at),
));
}
#[Override]
public function findActiveByReplacementTeacher(
UserId $replacementTeacherId,
TenantId $tenantId,
DateTimeImmutable $at,
): array {
return array_values(array_filter(
$this->byId,
static fn (TeacherReplacement $r) => $r->tenantId->equals($tenantId)
&& $r->replacementTeacherId->equals($replacementTeacherId)
&& $r->isActive($at),
));
}
#[Override]
public function findExpired(DateTimeImmutable $at): array
{
return array_values(array_filter(
$this->byId,
static fn (TeacherReplacement $r) => $r->status === ReplacementStatus::ACTIVE
&& $r->isExpired($at),
));
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use function in_array;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter pour les autorisations sur les remplacements enseignants.
*
* Règles d'accès :
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
* - ENSEIGNANT : lecture seule (ses propres remplacements actifs/passifs)
* - Autres rôles : pas d'accès
*
* @extends Voter<string, TeacherReplacement|null>
*/
final class TeacherReplacementVoter extends Voter
{
public const string VIEW = 'TEACHER_REPLACEMENT_VIEW';
public const string CREATE = 'TEACHER_REPLACEMENT_CREATE';
public const string DELETE = 'TEACHER_REPLACEMENT_DELETE';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::CREATE,
self::DELETE,
];
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) {
return false;
}
if ($subject === null) {
return true;
}
return $subject instanceof TeacherReplacement;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof SecurityUser) {
return false;
}
$roles = $user->getRoles();
return match ($attribute) {
self::VIEW => $this->canView($roles, $user, $subject),
self::CREATE => $this->canCreate($roles),
self::DELETE => $this->canDelete($roles),
default => false,
};
}
/** @param string[] $roles */
private function canView(array $roles, SecurityUser $user, mixed $subject): bool
{
if ($this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
])) {
return true;
}
if ($this->hasAnyRole($roles, [Role::PROF->value])) {
return $this->isInvolvedTeacher($user, $subject);
}
return false;
}
private function isInvolvedTeacher(SecurityUser $user, mixed $subject): bool
{
if (!$subject instanceof TeacherReplacement) {
return false;
}
return (string) $subject->replacedTeacherId === $user->userId()
|| (string) $subject->replacementTeacherId === $user->userId();
}
/** @param string[] $roles */
private function canCreate(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/** @param string[] $roles */
private function canDelete(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/**
* @param string[] $userRoles
* @param string[] $allowedRoles
*/
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
{
foreach ($userRoles as $role) {
if (in_array($role, $allowedRoles, true)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\DesignateReplacement;
use App\Administration\Domain\Exception\TenantMismatchException;
use App\Administration\Domain\Exception\UserNotFoundException;
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\Scolarite\Application\Command\DesignateReplacement\DesignateReplacementCommand;
use App\Scolarite\Application\Command\DesignateReplacement\DesignateReplacementHandler;
use App\Scolarite\Domain\Exception\DatesRemplacementInvalidesException;
use App\Scolarite\Domain\Exception\RemplacementSameTeacherException;
use App\Scolarite\Domain\Exception\UtilisateurNonEnseignantException;
use App\Scolarite\Domain\Model\TeacherReplacement\ReplacementStatus;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DesignateReplacementHandlerTest 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 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 ADMIN_USER_ID = '550e8400-e29b-41d4-a716-446655440050';
private InMemoryTeacherReplacementRepository $replacementRepository;
private InMemoryUserRepository $userRepository;
private Clock $clock;
protected function setUp(): void
{
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
$this->userRepository = new InMemoryUserRepository();
$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
{
$handler = $this->createHandler();
$command = $this->createCommand();
$replacement = $handler($command);
self::assertNotEmpty((string) $replacement->id);
self::assertSame(ReplacementStatus::ACTIVE, $replacement->status);
self::assertNull($replacement->endedAt);
}
#[Test]
public function itPersistsReplacementInRepository(): void
{
$handler = $this->createHandler();
$command = $this->createCommand();
$created = $handler($command);
$replacement = $this->replacementRepository->get(
TeacherReplacementId::fromString((string) $created->id),
TenantId::fromString(self::TENANT_ID),
);
self::assertSame(ReplacementStatus::ACTIVE, $replacement->status);
self::assertCount(1, $replacement->classes);
}
#[Test]
public function itSetsAllPropertiesCorrectly(): void
{
$handler = $this->createHandler();
$command = $this->createCommand();
$replacement = $handler($command);
self::assertTrue($replacement->replacedTeacherId->equals(UserId::fromString(self::REPLACED_TEACHER_ID)));
self::assertTrue($replacement->replacementTeacherId->equals(UserId::fromString(self::REPLACEMENT_TEACHER_ID)));
self::assertEquals(new DateTimeImmutable('2026-03-01'), $replacement->startDate);
self::assertEquals(new DateTimeImmutable('2026-03-31'), $replacement->endDate);
self::assertSame('Congé maladie', $replacement->reason);
}
#[Test]
public function itThrowsWhenReplacedTeacherDoesNotExist(): void
{
$handler = $this->createHandler();
$this->expectException(UserNotFoundException::class);
$handler($this->createCommand(replacedTeacherId: '550e8400-e29b-41d4-a716-446655440088'));
}
#[Test]
public function itThrowsWhenReplacementTeacherDoesNotExist(): void
{
$handler = $this->createHandler();
$this->expectException(UserNotFoundException::class);
$handler($this->createCommand(replacementTeacherId: '550e8400-e29b-41d4-a716-446655440088'));
}
#[Test]
public function itThrowsWhenSameTeacher(): void
{
$handler = $this->createHandler();
$this->expectException(RemplacementSameTeacherException::class);
$handler($this->createCommand(replacementTeacherId: self::REPLACED_TEACHER_ID));
}
#[Test]
public function itThrowsWhenEndDateBeforeStartDate(): void
{
$handler = $this->createHandler();
$this->expectException(DatesRemplacementInvalidesException::class);
$handler($this->createCommand(startDate: '2026-03-31', endDate: '2026-03-01'));
}
#[Test]
public function itThrowsWhenReplacedTeacherBelongsToDifferentTenant(): void
{
$handler = $this->createHandler();
$this->expectException(TenantMismatchException::class);
$handler($this->createCommand(replacedTeacherId: self::OTHER_TENANT_TEACHER_ID));
}
#[Test]
public function itThrowsWhenReplacementTeacherBelongsToDifferentTenant(): void
{
$handler = $this->createHandler();
$this->expectException(TenantMismatchException::class);
$handler($this->createCommand(replacementTeacherId: self::OTHER_TENANT_TEACHER_ID));
}
#[Test]
public function itThrowsWhenReplacedTeacherIsNotATeacher(): void
{
$handler = $this->createHandler();
$this->expectException(UtilisateurNonEnseignantException::class);
$handler($this->createCommand(replacedTeacherId: self::ADMIN_USER_ID));
}
#[Test]
public function itThrowsWhenReplacementTeacherIsNotATeacher(): void
{
$handler = $this->createHandler();
$this->expectException(UtilisateurNonEnseignantException::class);
$handler($this->createCommand(replacementTeacherId: self::ADMIN_USER_ID));
}
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 admin (pas enseignant) dans le même tenant
$adminUser = User::reconstitute(
id: UserId::fromString(self::ADMIN_USER_ID),
email: new Email('admin@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($adminUser);
}
private function createHandler(): DesignateReplacementHandler
{
return new DesignateReplacementHandler(
$this->replacementRepository,
$this->userRepository,
$this->clock,
);
}
private function createCommand(
?string $replacedTeacherId = null,
?string $replacementTeacherId = null,
?string $startDate = null,
?string $endDate = null,
): DesignateReplacementCommand {
return new DesignateReplacementCommand(
tenantId: self::TENANT_ID,
replacedTeacherId: $replacedTeacherId ?? self::REPLACED_TEACHER_ID,
replacementTeacherId: $replacementTeacherId ?? self::REPLACEMENT_TEACHER_ID,
startDate: $startDate ?? '2026-03-01',
endDate: $endDate ?? '2026-03-31',
classes: [
['classId' => self::CLASS_ID, 'subjectId' => self::SUBJECT_ID],
],
reason: 'Congé maladie',
createdBy: self::CREATED_BY_ID,
);
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\EndReplacement;
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\EndReplacementCommand;
use App\Scolarite\Application\Command\EndReplacement\EndReplacementHandler;
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
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\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class EndReplacementHandlerTest 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 CREATED_BY_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryTeacherReplacementRepository $replacementRepository;
private Clock $clock;
protected function setUp(): void
{
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
$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();
$handler = $this->createHandler();
$handler(new EndReplacementCommand(
replacementId: (string) $replacement->id,
tenantId: self::TENANT_ID,
));
$ended = $this->replacementRepository->get($replacement->id, TenantId::fromString(self::TENANT_ID));
self::assertSame(ReplacementStatus::ENDED, $ended->status);
self::assertNotNull($ended->endedAt);
}
#[Test]
public function itThrowsWhenReplacementNotFound(): void
{
$handler = $this->createHandler();
$this->expectException(RemplacementNotFoundException::class);
$handler(new EndReplacementCommand(
replacementId: '550e8400-e29b-41d4-a716-446655440088',
tenantId: self::TENANT_ID,
));
}
#[Test]
public function itThrowsWhenReplacementAlreadyEnded(): void
{
$replacement = $this->createAndSaveReplacement();
$replacement->terminer(new DateTimeImmutable('2026-03-10'));
$this->replacementRepository->save($replacement);
$handler = $this->createHandler();
$this->expectException(RemplacementDejaTermineException::class);
$handler(new EndReplacementCommand(
replacementId: (string) $replacement->id,
tenantId: self::TENANT_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('550e8400-e29b-41d4-a716-446655440020'),
SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
),
],
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 createHandler(): EndReplacementHandler
{
return new EndReplacementHandler(
$this->replacementRepository,
$this->clock,
);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetActiveReplacements;
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\Application\Query\GetActiveReplacements\GetActiveReplacementsQuery;
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\Persistence\InMemory\InMemoryTeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetActiveReplacementsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
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 DateTimeImmutable $now;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherReplacementRepository();
$this->now = new DateTimeImmutable('2026-03-15 10:00:00');
}
#[Test]
public function itReturnsActiveReplacementsForTenant(): void
{
$this->saveActiveReplacement();
$handler = $this->createHandler();
$result = $handler(new GetActiveReplacementsQuery(self::TENANT_ID));
self::assertCount(1, $result);
self::assertContainsOnlyInstancesOf(ReplacementDto::class, $result);
}
#[Test]
public function itReturnsEmptyArrayWhenNoneExist(): void
{
$handler = $this->createHandler();
$result = $handler(new GetActiveReplacementsQuery(self::TENANT_ID));
self::assertSame([], $result);
}
#[Test]
public function itMapsCorrectlyToDtos(): void
{
$replacement = $this->saveActiveReplacement();
$handler = $this->createHandler();
$result = $handler(new GetActiveReplacementsQuery(self::TENANT_ID));
self::assertCount(1, $result);
$dto = $result[0];
self::assertSame((string) $replacement->id, $dto->id);
self::assertSame(self::REPLACED_TEACHER_ID, $dto->replacedTeacherId);
self::assertSame(self::REPLACEMENT_TEACHER_ID, $dto->replacementTeacherId);
self::assertEquals(new DateTimeImmutable('2026-03-01'), $dto->startDate);
self::assertEquals(new DateTimeImmutable('2026-03-31'), $dto->endDate);
self::assertSame(ReplacementStatus::ACTIVE->value, $dto->status);
self::assertSame('Congé maladie', $dto->reason);
self::assertCount(1, $dto->classes);
self::assertSame(self::CLASS_ID, $dto->classes[0]['classId']);
self::assertSame(self::SUBJECT_ID, $dto->classes[0]['subjectId']);
}
#[Test]
public function itFiltersOutExpiredReplacements(): void
{
$this->saveReplacement(
startDate: new DateTimeImmutable('2026-01-01'),
endDate: new DateTimeImmutable('2026-02-01'),
);
$handler = $this->createHandler();
$result = $handler(new GetActiveReplacementsQuery(self::TENANT_ID));
self::assertSame([], $result);
}
#[Test]
public function itFiltersOutEndedReplacements(): void
{
$replacement = $this->saveActiveReplacement();
$replacement->terminer(new DateTimeImmutable('2026-03-10'));
$this->repository->save($replacement);
$handler = $this->createHandler();
$result = $handler(new GetActiveReplacementsQuery(self::TENANT_ID));
self::assertSame([], $result);
}
#[Test]
public function itDoesNotReturnReplacementsFromOtherTenants(): void
{
$this->saveReplacement(tenantId: self::OTHER_TENANT_ID);
$handler = $this->createHandler();
$result = $handler(new GetActiveReplacementsQuery(self::TENANT_ID));
self::assertSame([], $result);
}
private function saveActiveReplacement(): TeacherReplacement
{
return $this->saveReplacement();
}
private function saveReplacement(
?string $tenantId = null,
?DateTimeImmutable $startDate = null,
?DateTimeImmutable $endDate = null,
): TeacherReplacement {
$replacement = TeacherReplacement::designer(
tenantId: TenantId::fromString($tenantId ?? self::TENANT_ID),
replacedTeacherId: UserId::fromString(self::REPLACED_TEACHER_ID),
replacementTeacherId: UserId::fromString(self::REPLACEMENT_TEACHER_ID),
startDate: $startDate ?? new DateTimeImmutable('2026-03-01'),
endDate: $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'),
);
$this->repository->save($replacement);
return $replacement;
}
private function createHandler(): GetActiveReplacementsHandler
{
$now = $this->now;
$clock = new class($now) implements Clock {
public function __construct(private readonly DateTimeImmutable $now)
{
}
public function now(): DateTimeImmutable
{
return $this->now;
}
};
return new GetActiveReplacementsHandler($this->repository, $clock);
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetActiveReplacements;
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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ReplacementDtoTest 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 fromDomainCreatesCorrectDto(): void
{
$replacement = $this->createReplacement();
$dto = ReplacementDto::fromDomain($replacement);
self::assertSame((string) $replacement->id, $dto->id);
self::assertSame(self::REPLACED_TEACHER_ID, $dto->replacedTeacherId);
self::assertSame(self::REPLACEMENT_TEACHER_ID, $dto->replacementTeacherId);
self::assertEquals(new DateTimeImmutable('2026-03-01'), $dto->startDate);
self::assertEquals(new DateTimeImmutable('2026-03-31'), $dto->endDate);
self::assertSame(ReplacementStatus::ACTIVE->value, $dto->status);
self::assertSame('Congé maladie', $dto->reason);
}
#[Test]
public function fromDomainPreservesAllClassSubjectPairs(): void
{
$replacement = $this->createReplacementWithMultipleClasses();
$dto = ReplacementDto::fromDomain($replacement);
self::assertCount(2, $dto->classes);
self::assertSame(self::CLASS_ID, $dto->classes[0]['classId']);
self::assertSame(self::SUBJECT_ID, $dto->classes[0]['subjectId']);
self::assertSame('550e8400-e29b-41d4-a716-446655440021', $dto->classes[1]['classId']);
self::assertSame('550e8400-e29b-41d4-a716-446655440031', $dto->classes[1]['subjectId']);
}
#[Test]
public function fromDomainPreservesNullReason(): void
{
$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'),
);
$dto = ReplacementDto::fromDomain($replacement);
self::assertNull($dto->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'),
);
}
private function createReplacementWithMultipleClasses(): 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),
),
new ClassSubjectPair(
ClassId::fromString('550e8400-e29b-41d4-a716-446655440021'),
SubjectId::fromString('550e8400-e29b-41d4-a716-446655440031'),
),
],
reason: 'Congé maladie',
createdBy: UserId::fromString(self::CREATED_BY_ID),
now: new DateTimeImmutable('2026-02-15 10:00:00'),
);
}
}

View File

@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetReplacedClassesForTeacher;
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\Scolarite\Application\Query\GetReplacedClassesForTeacher\GetReplacedClassesForTeacherHandler;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\GetReplacedClassesForTeacherQuery;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\ReplacedClassDto;
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\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetReplacedClassesForTeacherHandlerTest 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 OTHER_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440012';
private const string CLASS_ID_1 = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID_1 = '550e8400-e29b-41d4-a716-446655440030';
private const string CLASS_ID_2 = '550e8400-e29b-41d4-a716-446655440021';
private const string SUBJECT_ID_2 = '550e8400-e29b-41d4-a716-446655440031';
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 DateTimeImmutable $now;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherReplacementRepository();
$this->classRepository = new InMemoryClassRepository();
$this->subjectRepository = new InMemorySubjectRepository();
$this->now = new DateTimeImmutable('2026-03-15 10:00:00');
$this->seedClassesAndSubjects();
}
#[Test]
public function itReturnsReplacedClassesForTeacher(): void
{
$this->saveReplacement();
$handler = $this->createHandler();
$result = $handler(new GetReplacedClassesForTeacherQuery(
replacementTeacherId: self::REPLACEMENT_TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertContainsOnlyInstancesOf(ReplacedClassDto::class, $result);
}
#[Test]
public function itReturnsEmptyWhenNoReplacements(): void
{
$handler = $this->createHandler();
$result = $handler(new GetReplacedClassesForTeacherQuery(
replacementTeacherId: self::REPLACEMENT_TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertSame([], $result);
}
#[Test]
public function itFlattensClassSubjectPairs(): void
{
$this->saveReplacementWithMultipleClasses();
$handler = $this->createHandler();
$result = $handler(new GetReplacedClassesForTeacherQuery(
replacementTeacherId: self::REPLACEMENT_TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(2, $result);
$classIds = array_map(static fn (ReplacedClassDto $dto) => $dto->classId, $result);
self::assertContains(self::CLASS_ID_1, $classIds);
self::assertContains(self::CLASS_ID_2, $classIds);
$subjectIds = array_map(static fn (ReplacedClassDto $dto) => $dto->subjectId, $result);
self::assertContains(self::SUBJECT_ID_1, $subjectIds);
self::assertContains(self::SUBJECT_ID_2, $subjectIds);
}
#[Test]
public function itOnlyReturnsActiveReplacements(): void
{
$replacement = $this->saveReplacement();
$replacement->terminer(new DateTimeImmutable('2026-03-10'));
$this->repository->save($replacement);
$handler = $this->createHandler();
$result = $handler(new GetReplacedClassesForTeacherQuery(
replacementTeacherId: self::REPLACEMENT_TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertSame([], $result);
}
#[Test]
public function itDoesNotReturnReplacementsForOtherTeachers(): void
{
$this->saveReplacement();
$handler = $this->createHandler();
$result = $handler(new GetReplacedClassesForTeacherQuery(
replacementTeacherId: self::OTHER_TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertSame([], $result);
}
#[Test]
public function itMapsDtoFieldsCorrectly(): void
{
$replacement = $this->saveReplacement();
$handler = $this->createHandler();
$result = $handler(new GetReplacedClassesForTeacherQuery(
replacementTeacherId: self::REPLACEMENT_TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
$dto = $result[0];
self::assertSame((string) $replacement->id, $dto->replacementId);
self::assertSame(self::REPLACED_TEACHER_ID, $dto->replacedTeacherId);
self::assertSame(self::CLASS_ID_1, $dto->classId);
self::assertSame(self::SUBJECT_ID_1, $dto->subjectId);
self::assertSame('6ème A', $dto->className);
self::assertSame('Mathématiques', $dto->subjectName);
self::assertEquals(new DateTimeImmutable('2026-03-01'), $dto->startDate);
self::assertEquals(new DateTimeImmutable('2026-03-31'), $dto->endDate);
}
private function saveReplacement(): 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_1),
SubjectId::fromString(self::SUBJECT_ID_1),
),
],
reason: null,
createdBy: UserId::fromString(self::CREATED_BY_ID),
now: new DateTimeImmutable('2026-02-15 10:00:00'),
);
$this->repository->save($replacement);
return $replacement;
}
private function saveReplacementWithMultipleClasses(): 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_1),
SubjectId::fromString(self::SUBJECT_ID_1),
),
new ClassSubjectPair(
ClassId::fromString(self::CLASS_ID_2),
SubjectId::fromString(self::SUBJECT_ID_2),
),
],
reason: null,
createdBy: UserId::fromString(self::CREATED_BY_ID),
now: new DateTimeImmutable('2026-02-15 10:00:00'),
);
$this->repository->save($replacement);
return $replacement;
}
private function seedClassesAndSubjects(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$now = new DateTimeImmutable('2026-01-01');
$class1 = SchoolClass::reconstitute(
ClassId::fromString(self::CLASS_ID_1), $tenantId, $schoolId, $academicYearId,
new ClassName('6ème A'), null, null, ClassStatus::ACTIVE, null, $now, $now, null,
);
$this->classRepository->save($class1);
$class2 = SchoolClass::reconstitute(
ClassId::fromString(self::CLASS_ID_2), $tenantId, $schoolId, $academicYearId,
new ClassName('5ème B'), null, null, ClassStatus::ACTIVE, null, $now, $now, null,
);
$this->classRepository->save($class2);
$subject1 = Subject::reconstitute(
SubjectId::fromString(self::SUBJECT_ID_1), $tenantId, $schoolId,
new SubjectName('Mathématiques'), new SubjectCode('MATH'), null,
SubjectStatus::ACTIVE,
null, $now, $now, null,
);
$this->subjectRepository->save($subject1);
$subject2 = Subject::reconstitute(
SubjectId::fromString(self::SUBJECT_ID_2), $tenantId, $schoolId,
new SubjectName('Français'), new SubjectCode('FR'), null,
SubjectStatus::ACTIVE,
null, $now, $now, null,
);
$this->subjectRepository->save($subject2);
}
private function createHandler(): GetReplacedClassesForTeacherHandler
{
$now = $this->now;
$clock = new class($now) implements Clock {
public function __construct(private readonly DateTimeImmutable $now)
{
}
public function now(): DateTimeImmutable
{
return $this->now;
}
};
return new GetReplacedClassesForTeacherHandler(
$this->repository,
$this->classRepository,
$this->subjectRepository,
$clock,
);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetReplacedClassesForTeacher;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\ReplacedClassDto;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ReplacedClassDtoTest extends TestCase
{
private const string REPLACEMENT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string REPLACED_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
#[Test]
public function constructorSetsAllPropertiesCorrectly(): void
{
$startDate = new DateTimeImmutable('2026-03-01');
$endDate = new DateTimeImmutable('2026-03-31');
$dto = new ReplacedClassDto(
replacementId: self::REPLACEMENT_ID,
replacedTeacherId: self::REPLACED_TEACHER_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
className: '6ème A',
subjectName: 'Mathématiques',
startDate: $startDate,
endDate: $endDate,
);
self::assertSame(self::REPLACEMENT_ID, $dto->replacementId);
self::assertSame(self::REPLACED_TEACHER_ID, $dto->replacedTeacherId);
self::assertSame(self::CLASS_ID, $dto->classId);
self::assertSame(self::SUBJECT_ID, $dto->subjectId);
self::assertSame('6ème A', $dto->className);
self::assertSame('Mathématiques', $dto->subjectName);
self::assertEquals($startDate, $dto->startDate);
self::assertEquals($endDate, $dto->endDate);
}
#[Test]
public function constructorPreservesExactDateValues(): void
{
$startDate = new DateTimeImmutable('2026-03-01 08:00:00');
$endDate = new DateTimeImmutable('2026-03-31 23:59:59');
$dto = new ReplacedClassDto(
replacementId: self::REPLACEMENT_ID,
replacedTeacherId: self::REPLACED_TEACHER_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
className: '6ème A',
subjectName: 'Mathématiques',
startDate: $startDate,
endDate: $endDate,
);
self::assertSame($startDate, $dto->startDate);
self::assertSame($endDate, $dto->endDate);
}
}

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),
),
];
}
}

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'),
);
}
}

View File

@@ -0,0 +1,188 @@
<?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'),
);
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Persistence\InMemory;
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\Exception\RemplacementNotFoundException;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
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 InMemoryTeacherReplacementRepositoryTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
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 OTHER_REPLACEMENT_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440012';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string OTHER_CLASS_ID = '550e8400-e29b-41d4-a716-446655440021';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string OTHER_SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440031';
private const string CREATED_BY_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryTeacherReplacementRepository $repository;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherReplacementRepository();
}
#[Test]
public function saveAndGet(): void
{
$replacement = $this->createReplacement();
$this->repository->save($replacement);
$found = $this->repository->get($replacement->id, $replacement->tenantId);
self::assertSame($replacement, $found);
}
#[Test]
public function findByIdReturnsNullWhenNotFound(): void
{
$result = $this->repository->findById(
TeacherReplacementId::fromString('550e8400-e29b-41d4-a716-446655440099'),
TenantId::fromString(self::TENANT_ID),
);
self::assertNull($result);
}
#[Test]
public function findByIdReturnsNullWhenTenantDoesNotMatch(): void
{
$replacement = $this->createReplacement();
$this->repository->save($replacement);
$result = $this->repository->findById(
$replacement->id,
TenantId::fromString(self::OTHER_TENANT_ID),
);
self::assertNull($result);
}
#[Test]
public function getThrowsWhenNotFound(): void
{
$this->expectException(RemplacementNotFoundException::class);
$this->repository->get(
TeacherReplacementId::fromString('550e8400-e29b-41d4-a716-446655440099'),
TenantId::fromString(self::TENANT_ID),
);
}
#[Test]
public function findActiveByTenantReturnsOnlyActiveInDateRange(): void
{
$activeReplacement = $this->createReplacement();
$this->repository->save($activeReplacement);
$endedReplacement = $this->createReplacement(
replacementTeacherId: self::OTHER_REPLACEMENT_TEACHER_ID,
);
$endedReplacement->terminer(new DateTimeImmutable('2026-03-10'));
$this->repository->save($endedReplacement);
$result = $this->repository->findActiveByTenant(
TenantId::fromString(self::TENANT_ID),
new DateTimeImmutable('2026-03-15'),
);
self::assertCount(1, $result);
self::assertSame($activeReplacement, $result[0]);
}
#[Test]
public function findActiveByTenantExcludesExpiredReplacements(): void
{
$replacement = $this->createReplacement(
startDate: '2026-02-01',
endDate: '2026-02-28',
);
$this->repository->save($replacement);
$result = $this->repository->findActiveByTenant(
TenantId::fromString(self::TENANT_ID),
new DateTimeImmutable('2026-03-15'),
);
self::assertSame([], $result);
}
#[Test]
public function findActiveByReplacementTeacherFiltersCorrectly(): void
{
$replacement = $this->createReplacement();
$this->repository->save($replacement);
$otherReplacement = $this->createReplacement(
replacementTeacherId: self::OTHER_REPLACEMENT_TEACHER_ID,
);
$this->repository->save($otherReplacement);
$result = $this->repository->findActiveByReplacementTeacher(
UserId::fromString(self::REPLACEMENT_TEACHER_ID),
TenantId::fromString(self::TENANT_ID),
new DateTimeImmutable('2026-03-15'),
);
self::assertCount(1, $result);
self::assertSame($replacement, $result[0]);
}
#[Test]
public function findActiveByReplacementTeacherExcludesInactiveReplacements(): void
{
$replacement = $this->createReplacement();
$this->repository->save($replacement);
$result = $this->repository->findActiveByReplacementTeacher(
UserId::fromString(self::REPLACEMENT_TEACHER_ID),
TenantId::fromString(self::TENANT_ID),
new DateTimeImmutable('2026-04-15'),
);
self::assertSame([], $result);
}
#[Test]
public function findActiveReplacementMatchesClassSubjectPair(): void
{
$replacement = $this->createReplacement();
$this->repository->save($replacement);
$result = $this->repository->findActiveReplacement(
UserId::fromString(self::REPLACEMENT_TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
new DateTimeImmutable('2026-03-15'),
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($result);
self::assertSame($replacement, $result);
}
#[Test]
public function findActiveReplacementReturnsNullForNonMatchingPair(): void
{
$replacement = $this->createReplacement();
$this->repository->save($replacement);
$result = $this->repository->findActiveReplacement(
UserId::fromString(self::REPLACEMENT_TEACHER_ID),
ClassId::fromString(self::OTHER_CLASS_ID),
SubjectId::fromString(self::OTHER_SUBJECT_ID),
new DateTimeImmutable('2026-03-15'),
TenantId::fromString(self::TENANT_ID),
);
self::assertNull($result);
}
#[Test]
public function findExpiredReturnsOnlyExpiredAcrossTenants(): void
{
$expiredReplacement = $this->createReplacement(
startDate: '2026-02-01',
endDate: '2026-02-28',
);
$this->repository->save($expiredReplacement);
$expiredOtherTenant = $this->createReplacement(
tenantId: self::OTHER_TENANT_ID,
startDate: '2026-02-01',
endDate: '2026-02-28',
replacementTeacherId: self::OTHER_REPLACEMENT_TEACHER_ID,
);
$this->repository->save($expiredOtherTenant);
$activeReplacement = $this->createReplacement(
startDate: '2026-03-01',
endDate: '2026-04-30',
replacementTeacherId: '550e8400-e29b-41d4-a716-446655440013',
);
$this->repository->save($activeReplacement);
$result = $this->repository->findExpired(new DateTimeImmutable('2026-03-15'));
self::assertCount(2, $result);
self::assertContains($expiredReplacement, $result);
self::assertContains($expiredOtherTenant, $result);
}
#[Test]
public function findExpiredExcludesAlreadyEndedReplacements(): void
{
$replacement = $this->createReplacement(
startDate: '2026-02-01',
endDate: '2026-02-28',
);
$replacement->terminer(new DateTimeImmutable('2026-02-28'));
$this->repository->save($replacement);
$result = $this->repository->findExpired(new DateTimeImmutable('2026-03-15'));
self::assertSame([], $result);
}
private function createReplacement(
string $tenantId = self::TENANT_ID,
string $startDate = '2026-03-01',
string $endDate = '2026-03-31',
string $replacementTeacherId = self::REPLACEMENT_TEACHER_ID,
): TeacherReplacement {
return TeacherReplacement::designer(
tenantId: TenantId::fromString($tenantId),
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'),
);
}
}

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Security;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
final class TeacherReplacementVoterTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string REPLACED_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string REPLACEMENT_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440011';
private TeacherReplacementVoter $voter;
protected function setUp(): void
{
$this->voter = new TeacherReplacementVoter();
}
#[Test]
public function itAbstainsForUnrelatedAttributes(): void
{
$token = $this->tokenWithSecurityUser(Role::ADMIN->value);
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
}
#[Test]
public function itDeniesAccessToUnauthenticatedUsers(): void
{
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn(null);
$result = $this->voter->vote($token, null, [TeacherReplacementVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itDeniesAccessToNonSecurityUserInstances(): void
{
$user = $this->createMock(UserInterface::class);
$user->method('getRoles')->willReturn([Role::ADMIN->value]);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($user);
$result = $this->voter->vote($token, null, [TeacherReplacementVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
// --- VIEW ---
#[Test]
#[DataProvider('adminRolesProvider')]
public function itGrantsViewToAdminRoles(string $role): void
{
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, null, [TeacherReplacementVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itGrantsViewToReplacedTeacher(): void
{
$token = $this->tokenWithSecurityUser(Role::PROF->value, self::REPLACED_TEACHER_ID);
$replacement = $this->createReplacement();
$result = $this->voter->vote($token, $replacement, [TeacherReplacementVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itGrantsViewToReplacementTeacher(): void
{
$token = $this->tokenWithSecurityUser(Role::PROF->value, self::REPLACEMENT_TEACHER_ID);
$replacement = $this->createReplacement();
$result = $this->voter->vote($token, $replacement, [TeacherReplacementVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itDeniesViewToUninvolvedTeacher(): void
{
$token = $this->tokenWithSecurityUser(Role::PROF->value, '550e8400-e29b-41d4-a716-446655440099');
$replacement = $this->createReplacement();
$result = $this->voter->vote($token, $replacement, [TeacherReplacementVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itDeniesViewToTeacherWithoutSubject(): void
{
$token = $this->tokenWithSecurityUser(Role::PROF->value, self::REPLACED_TEACHER_ID);
$result = $this->voter->vote($token, null, [TeacherReplacementVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
#[DataProvider('nonStaffRolesProvider')]
public function itDeniesViewToNonStaffRoles(string $role): void
{
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, null, [TeacherReplacementVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
// --- CREATE ---
#[Test]
#[DataProvider('adminRolesProvider')]
public function itGrantsCreateToAdminRoles(string $role): void
{
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, null, [TeacherReplacementVoter::CREATE]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
#[DataProvider('nonAdminRolesProvider')]
public function itDeniesCreateToNonAdminRoles(string $role): void
{
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, null, [TeacherReplacementVoter::CREATE]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
// --- DELETE ---
#[Test]
#[DataProvider('adminRolesProvider')]
public function itGrantsDeleteToAdminRoles(string $role): void
{
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, null, [TeacherReplacementVoter::DELETE]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
#[DataProvider('nonAdminRolesProvider')]
public function itDeniesDeleteToNonAdminRoles(string $role): void
{
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, null, [TeacherReplacementVoter::DELETE]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
// --- Data Providers ---
/** @return iterable<string, array{string}> */
public static function adminRolesProvider(): iterable
{
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
yield 'ADMIN' => [Role::ADMIN->value];
}
/** @return iterable<string, array{string}> */
public static function nonAdminRolesProvider(): iterable
{
yield 'PROF' => [Role::PROF->value];
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
yield 'PARENT' => [Role::PARENT->value];
yield 'ELEVE' => [Role::ELEVE->value];
}
/** @return iterable<string, array{string}> */
public static function nonStaffRolesProvider(): iterable
{
yield 'PARENT' => [Role::PARENT->value];
yield 'ELEVE' => [Role::ELEVE->value];
}
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('550e8400-e29b-41d4-a716-446655440020'),
SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
),
],
reason: null,
createdBy: UserId::fromString('550e8400-e29b-41d4-a716-446655440099'),
now: new DateTimeImmutable('2026-02-15 10:00:00'),
);
}
private function tokenWithSecurityUser(
string $role,
string $userId = '550e8400-e29b-41d4-a716-446655440001',
): TokenInterface {
$securityUser = new SecurityUser(
UserId::fromString($userId),
'test@example.com',
'hashed_password',
TenantId::fromString(self::TENANT_ID),
[$role],
);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($securityUser);
return $token;
}
}

View File

@@ -0,0 +1,222 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const ADMIN_EMAIL = 'e2e-responsive-nav@example.com';
const ADMIN_PASSWORD = 'ResponsiveNav123';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
test.describe('Admin Responsive Navigation', () => {
test.beforeAll(async () => {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
});
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// =========================================================================
// MOBILE (375×667)
// =========================================================================
test.describe('Mobile (375×667)', () => {
test.use({ viewport: { width: 375, height: 667 } });
test('shows hamburger button and hides desktop nav', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
await expect(hamburger).toBeVisible();
const desktopNav = page.locator('.desktop-nav');
await expect(desktopNav).not.toBeVisible();
});
test('displays current section label', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const label = page.locator('.mobile-section-label');
await expect(label).toBeVisible();
await expect(label).toHaveText('Utilisateurs');
});
test('opens and closes menu via hamburger button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
// Open
await hamburger.click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
// Close via × button
const closeButton = page.getByRole('button', { name: /fermer le menu/i });
await closeButton.click();
await expect(drawer).not.toBeVisible();
});
test('closes menu on overlay click', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
// Click overlay (outside drawer)
const overlay = page.locator('.mobile-overlay');
await overlay.click({ position: { x: 350, y: 300 } });
await expect(drawer).not.toBeVisible();
});
test('closes menu on Escape key', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
await page.keyboard.press('Escape');
await expect(drawer).not.toBeVisible();
});
test('shows active state for current section in mobile menu', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
const activeLink = drawer.locator('.mobile-nav-link.active');
await expect(activeLink).toHaveText('Utilisateurs');
});
test('navigates via mobile menu and closes it', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
// Open menu and click Classes
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
await drawer.getByRole('link', { name: 'Classes' }).click();
// Menu should close and page should navigate
await expect(drawer).not.toBeVisible();
await expect(page).toHaveURL(/\/admin\/classes/);
// Section label should update
const label = page.locator('.mobile-section-label');
await expect(label).toHaveText('Classes');
});
});
// =========================================================================
// TABLET (768×1024)
// =========================================================================
test.describe('Tablet (768×1024)', () => {
test.use({ viewport: { width: 768, height: 1024 } });
test('shows hamburger button (below 1200px)', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
await expect(hamburger).toBeVisible();
const desktopNav = page.locator('.desktop-nav');
await expect(desktopNav).not.toBeVisible();
});
test('drawer opens and works', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
// All nav links should be visible in drawer
await expect(drawer.getByRole('link', { name: 'Utilisateurs' })).toBeVisible();
await expect(drawer.getByRole('link', { name: 'Classes' })).toBeVisible();
await expect(drawer.getByRole('link', { name: 'Matières' })).toBeVisible();
});
});
// =========================================================================
// DESKTOP (1280×800)
// =========================================================================
test.describe('Desktop (1280×800)', () => {
test.use({ viewport: { width: 1280, height: 800 } });
test('hides hamburger and shows desktop nav', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
await expect(hamburger).not.toBeVisible();
const desktopNav = page.locator('.desktop-nav');
await expect(desktopNav).toBeVisible();
});
test('desktop nav shows all navigation links', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const nav = page.locator('.desktop-nav');
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Matières' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Affectations' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Périodes' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Pédagogie' })).toBeVisible();
});
test('hides mobile section label', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const label = page.locator('.mobile-section-label');
await expect(label).not.toBeVisible();
});
});
});

View File

@@ -284,12 +284,12 @@ test.describe('Periods Management (Story 2.3)', () => {
// Navigation // Navigation
// ============================================================================ // ============================================================================
test.describe('Navigation', () => { test.describe('Navigation', () => {
test('can access periods page from admin dashboard', async ({ page }) => { test('can access periods page from admin navigation', async ({ page }) => {
await loginAsAdmin(page); await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin`); await page.goto(`${ALPHA_URL}/admin`);
// Click on periods card // Click on periods link in the admin navigation
await page.getByRole('link', { name: /périodes scolaires/i }).click(); await page.getByRole('link', { name: /périodes/i }).click();
await expect(page).toHaveURL(/\/admin\/academic-year\/periods/); await expect(page).toHaveURL(/\/admin\/academic-year\/periods/);
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();

View File

@@ -96,9 +96,10 @@ test.describe('Role-Based Access Control [P0]', () => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`); await page.goto(`${ALPHA_URL}/admin`);
await expect(page).toHaveURL(/\/admin/); // /admin redirects to /admin/users
await expect(page).toHaveURL(/\/admin\/users/);
await expect( await expect(
page.getByRole('heading', { name: /administration/i }) page.getByRole('heading', { name: /gestion des utilisateurs/i })
).toBeVisible({ timeout: 10000 }); ).toBeVisible({ timeout: 10000 });
}); });
}); });
@@ -191,8 +192,8 @@ test.describe('Role-Based Access Control [P0]', () => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`); await page.goto(`${ALPHA_URL}/admin`);
// Admin layout should show navigation links (scoped to header nav to avoid action cards) // Admin layout should show navigation links (scoped to desktop nav to avoid action cards)
const nav = page.locator('.header-nav'); const nav = page.locator('.desktop-nav');
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible({ timeout: 15000 }); await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible({ timeout: 15000 });
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible(); await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
}); });

View File

@@ -0,0 +1,278 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const ADMIN_EMAIL = 'e2e-replacements-admin@example.com';
const ADMIN_PASSWORD = 'ReplacementsTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runCommand(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId, academicYearId };
}
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function waitForPageReady(page: import('@playwright/test').Page) {
await expect(
page.getByRole('heading', { name: /remplacements enseignants/i })
).toBeVisible({ timeout: 15000 });
await expect(
page.locator('.empty-state, .replacements-table, .alert-error')
).toBeVisible({ timeout: 15000 });
}
async function openCreateDialog(page: import('@playwright/test').Page) {
const button = page.getByRole('button', { name: /nouveau remplacement/i }).first();
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
}
function getTodayDate(): string {
return new Date().toISOString().split('T')[0];
}
function getFutureDate(days: number): string {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().split('T')[0];
}
test.describe('Teacher Replacements (Story 2.9)', () => {
test.beforeAll(async () => {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-replaced-teacher@example.com --password=TeacherTest123 --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-replacement-teacher@example.com --password=TeacherTest123 --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
runCommand(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Repl-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runCommand(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Repl-Français', 'E2EFRA', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
});
test.beforeEach(async () => {
try {
runCommand(`DELETE FROM replacement_classes WHERE replacement_id IN (SELECT id FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}')`);
runCommand(`DELETE FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Tables may not exist yet if migration hasn't run
}
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('replacements link appears in admin navigation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin`);
const navLink = page.getByRole('link', { name: /remplacements/i });
await expect(navLink).toBeVisible({ timeout: 15000 });
});
test('can navigate to replacements page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/replacements`);
await expect(page.getByRole('heading', { name: /remplacements enseignants/i })).toBeVisible({ timeout: 15000 });
});
});
// ============================================================================
// Empty State
// ============================================================================
test.describe('Empty State', () => {
test('shows empty state when no replacements exist', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/replacements`);
await waitForPageReady(page);
await expect(page.getByText(/aucun remplacement actif/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC1: Create Replacement
// ============================================================================
test.describe('AC1: Create Replacement', () => {
test('can create a new replacement', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/replacements`);
await waitForPageReady(page);
await openCreateDialog(page);
// Select replaced teacher
const replacedSelect = page.locator('#replaced-teacher');
await expect(replacedSelect).toBeVisible();
await replacedSelect.selectOption({ index: 1 });
// Select replacement teacher (different from replaced)
const replacementSelect = page.locator('#replacement-teacher');
await expect(replacementSelect).toBeVisible();
const replacementOptions = replacementSelect.locator('option');
const count = await replacementOptions.count();
// Select a different teacher (index 1 should work since replaced teacher is filtered out)
if (count > 1) {
await replacementSelect.selectOption({ index: 1 });
}
// Set dates
await page.locator('#start-date').fill(getTodayDate());
await page.locator('#end-date').fill(getFutureDate(30));
// Select class and subject
const firstClassSelect = page.locator('.class-pair-row select').first();
await firstClassSelect.selectOption({ index: 1 });
const firstSubjectSelect = page.locator('.class-pair-row select').nth(1);
await firstSubjectSelect.selectOption({ index: 1 });
// Submit
await page.getByRole('button', { name: /désigner le remplaçant/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(/remplacement créé/i)).toBeVisible({ timeout: 10000 });
// Verify table shows the replacement
const table = page.locator('.replacements-table');
await expect(table).toBeVisible({ timeout: 10000 });
const rows = table.locator('tbody tr');
const rowCount = await rows.count();
expect(rowCount).toBeGreaterThanOrEqual(1);
});
test('cancel closes the modal without creating', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/replacements`);
await waitForPageReady(page);
await openCreateDialog(page);
await page.getByRole('button', { name: /annuler/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
});
});
// ============================================================================
// AC3: End Replacement
// ============================================================================
test.describe('AC3: End Replacement', () => {
test('can end an active replacement', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/replacements`);
await waitForPageReady(page);
// First create a replacement
await openCreateDialog(page);
await page.locator('#replaced-teacher').selectOption({ index: 1 });
await page.locator('#replacement-teacher').selectOption({ index: 1 });
await page.locator('#start-date').fill(getTodayDate());
await page.locator('#end-date').fill(getFutureDate(30));
const firstClassSelect = page.locator('.class-pair-row select').first();
await firstClassSelect.selectOption({ index: 1 });
const firstSubjectSelect = page.locator('.class-pair-row select').nth(1);
await firstSubjectSelect.selectOption({ index: 1 });
await page.getByRole('button', { name: /désigner le remplaçant/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Now end it
const endButton = page.locator('.btn-remove').first();
await endButton.click();
const confirmDialog = page.getByRole('alertdialog');
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
await expect(page.getByText(/perdra immédiatement l'accès/i)).toBeVisible();
await confirmDialog.getByRole('button', { name: /terminer/i }).click();
await expect(confirmDialog).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC4: Active Replacements Display
// ============================================================================
test.describe('AC4: Active Replacements Display', () => {
test('shows countdown for active replacements', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/replacements`);
await waitForPageReady(page);
// Create a replacement
await openCreateDialog(page);
await page.locator('#replaced-teacher').selectOption({ index: 1 });
await page.locator('#replacement-teacher').selectOption({ index: 1 });
await page.locator('#start-date').fill(getTodayDate());
await page.locator('#end-date').fill(getFutureDate(10));
const firstClassSelect = page.locator('.class-pair-row select').first();
await firstClassSelect.selectOption({ index: 1 });
const firstSubjectSelect = page.locator('.class-pair-row select').nth(1);
await firstSubjectSelect.selectOption({ index: 1 });
await page.getByRole('button', { name: /désigner le remplaçant/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Verify countdown is displayed
await expect(page.locator('.countdown')).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/jours? restants?/i)).toBeVisible();
// Verify status badge
await expect(page.locator('.status-active')).toBeVisible();
});
});
});

View File

@@ -46,6 +46,11 @@
<span class="action-label">Affectations</span> <span class="action-label">Affectations</span>
<span class="action-hint">Enseignants et classes</span> <span class="action-hint">Enseignants et classes</span>
</a> </a>
<a class="action-card" href="/admin/replacements">
<span class="action-icon">🔄</span>
<span class="action-label">Remplacements</span>
<span class="action-hint">Enseignants absents</span>
</a>
<a class="action-card" href="/admin/academic-year/periods"> <a class="action-card" href="/admin/academic-year/periods">
<span class="action-icon">📅</span> <span class="action-icon">📅</span>
<span class="action-label">Périodes scolaires</span> <span class="action-label">Périodes scolaires</span>

View File

@@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte'; import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte'; import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch, isAuthenticated } from '$lib/auth';
import { untrack } from 'svelte';
let { let {
isLoading = false, isLoading = false,
@@ -9,6 +12,53 @@
isLoading?: boolean; isLoading?: boolean;
hasRealData?: boolean; hasRealData?: boolean;
} = $props(); } = $props();
interface ReplacedClass {
replacementId: string;
replacedTeacherId: string;
classId: string;
subjectId: string;
className: string;
subjectName: string;
startDate: string;
endDate: string;
}
let replacedClasses = $state<ReplacedClass[]>([]);
let replacementsLoading = $state(false);
$effect(() => {
untrack(() => {
if (isAuthenticated()) {
loadReplacedClasses();
}
});
});
async function loadReplacedClasses() {
try {
replacementsLoading = true;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/replaced-classes`);
if (!response.ok) return;
const data = await response.json();
replacedClasses = Array.isArray(data) ? data : (data['hydra:member'] ?? []);
} catch {
// Silently fail - not critical for dashboard display
} finally {
replacementsLoading = false;
}
}
function daysRemaining(endDate: string): number {
const end = new Date(endDate);
const now = new Date();
now.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
</script> </script>
<div class="dashboard-teacher"> <div class="dashboard-teacher">
@@ -35,6 +85,44 @@
</div> </div>
</div> </div>
{#if replacedClasses.length > 0}
<DashboardSection
title="Classes en remplacement"
subtitle="Vous remplacez actuellement un enseignant"
>
<div class="replacement-list">
{#each replacedClasses as rc}
{@const days = daysRemaining(rc.endDate)}
<div class="replacement-card">
<div class="replacement-badge">Remplacement</div>
<div class="replacement-info">
<span class="replacement-class">{rc.className}</span>
<span class="replacement-subject">{rc.subjectName}</span>
</div>
<div class="replacement-dates">
{new Date(rc.startDate).toLocaleDateString('fr-FR')}
&rarr;
{new Date(rc.endDate).toLocaleDateString('fr-FR')}
<span class="replacement-countdown" class:urgent={days <= 3}>
{#if days > 1}
({days} jours restants)
{:else if days === 1}
(1 jour restant)
{:else}
(Dernier jour)
{/if}
</span>
</div>
</div>
{/each}
</div>
</DashboardSection>
{:else if replacementsLoading}
<DashboardSection title="Classes en remplacement">
<SkeletonList items={2} message="Chargement des remplacements..." />
</DashboardSection>
{/if}
<div class="dashboard-grid"> <div class="dashboard-grid">
<DashboardSection <DashboardSection
title="Mes classes aujourd'hui" title="Mes classes aujourd'hui"
@@ -164,6 +252,62 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
/* Replacement section */
.replacement-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.replacement-card {
padding: 0.75rem 1rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.5rem;
}
.replacement-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
background: #3b82f6;
color: white;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.replacement-info {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.25rem;
}
.replacement-class {
font-weight: 600;
color: #1f2937;
}
.replacement-subject {
color: #4b5563;
font-size: 0.875rem;
}
.replacement-dates {
font-size: 0.8125rem;
color: #6b7280;
}
.replacement-countdown {
font-weight: 500;
color: #16a34a;
}
.replacement-countdown.urgent {
color: #dc2626;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.dashboard-grid { .dashboard-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);

View File

@@ -10,6 +10,7 @@
let isLoggingOut = $state(false); let isLoggingOut = $state(false);
let accessChecked = $state(false); let accessChecked = $state(false);
let hasAccess = $state(false); let hasAccess = $state(false);
let mobileMenuOpen = $state(false);
const ADMIN_ROLES = [ const ADMIN_ROLES = [
'ROLE_SUPER_ADMIN', 'ROLE_SUPER_ADMIN',
@@ -18,6 +19,17 @@
'ROLE_SECRETARIAT' 'ROLE_SECRETARIAT'
]; ];
const navLinks = [
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
{ href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive },
{ href: '/admin/replacements', label: 'Remplacements', isActive: () => isReplacementsActive },
{ href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive },
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive }
];
// Load user roles and verify admin access // Load user roles and verify admin access
onMount(async () => { onMount(async () => {
await fetchRoles(); await fetchRoles();
@@ -51,13 +63,65 @@
goto('/settings'); goto('/settings');
} }
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
function closeMobileMenu() {
mobileMenuOpen = false;
}
// Determine which admin section is active // Determine which admin section is active
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users')); const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes')); const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects')); const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods')); const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments')); const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments'));
const isReplacementsActive = $derived(page.url.pathname.startsWith('/admin/replacements'));
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy')); const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
const currentSectionLabel = $derived.by(() => {
const path = page.url.pathname;
for (const link of navLinks) {
if (link.href !== '/dashboard' && path.startsWith(link.href)) {
return link.label;
}
}
return 'Administration';
});
// Close menu on route change
$effect(() => {
void page.url.pathname;
mobileMenuOpen = false;
});
// Close menu on Escape key
$effect(() => {
if (!mobileMenuOpen) return;
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
mobileMenuOpen = false;
}
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
});
// Lock body scroll when menu is open
$effect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
});
</script> </script>
{#if !accessChecked} {#if !accessChecked}
@@ -71,15 +135,25 @@
<button class="logo-button" onclick={goHome}> <button class="logo-button" onclick={goHome}>
<span class="logo-text">Classeo</span> <span class="logo-text">Classeo</span>
</button> </button>
<nav class="header-nav">
<span class="mobile-section-label">{currentSectionLabel}</span>
<button
class="hamburger-button"
onclick={toggleMobileMenu}
aria-expanded={mobileMenuOpen}
aria-label="Ouvrir le menu de navigation"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<nav class="desktop-nav">
<RoleSwitcher /> <RoleSwitcher />
<a href="/dashboard" class="nav-link">Tableau de bord</a> {#each navLinks as link}
<a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a> <a href={link.href} class="nav-link" class:active={link.isActive()}>{link.label}</a>
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a> {/each}
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
<a href="/admin/assignments" class="nav-link" class:active={isAssignmentsActive}>Affectations</a>
<a href="/admin/academic-year/periods" class="nav-link" class:active={isPeriodsActive}>Périodes</a>
<a href="/admin/pedagogy" class="nav-link" class:active={isPedagogyActive}>Pédagogie</a>
<button class="nav-button" onclick={goSettings}>Paramètres</button> <button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}> <button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
{#if isLoggingOut} {#if isLoggingOut}
@@ -93,6 +167,60 @@
</div> </div>
</header> </header>
{#if mobileMenuOpen}
<div
class="mobile-overlay"
onclick={closeMobileMenu}
onkeydown={(e) => e.key === 'Enter' && closeMobileMenu()}
role="presentation"
></div>
<div
class="mobile-drawer"
role="dialog"
aria-modal="true"
aria-label="Menu de navigation"
>
<div class="mobile-drawer-header">
<span class="logo-text">Classeo</span>
<button
class="mobile-close"
onclick={closeMobileMenu}
aria-label="Fermer le menu"
>
&times;
</button>
</div>
<div class="mobile-drawer-body">
<div class="mobile-role-switcher">
<RoleSwitcher />
</div>
{#each navLinks as link}
<a
href={link.href}
class="mobile-nav-link"
class:active={link.isActive()}
>
{link.label}
</a>
{/each}
</div>
<div class="mobile-drawer-footer">
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
<button
class="mobile-nav-link mobile-logout"
onclick={handleLogout}
disabled={isLoggingOut}
>
{#if isLoggingOut}
Déconnexion...
{:else}
Déconnexion
{/if}
</button>
</div>
</div>
{/if}
<main class="admin-main"> <main class="admin-main">
<div class="main-content"> <div class="main-content">
{@render children()} {@render children()}
@@ -131,9 +259,8 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; height: 56px;
height: auto; padding: 0;
padding: 0.75rem 0;
gap: 0.75rem; gap: 0.75rem;
} }
@@ -150,23 +277,64 @@
color: var(--accent-primary, #0ea5e9); color: var(--accent-primary, #0ea5e9);
} }
.header-nav { /* Mobile section label */
.mobile-section-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #1f2937);
flex: 1;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Hamburger button */
.hamburger-button {
display: flex; display: flex;
flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
width: 100%; gap: 5px;
justify-content: flex-end; width: 40px;
flex-wrap: wrap; height: 40px;
gap: 0.5rem; background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 0.5rem;
flex-shrink: 0;
}
.hamburger-button:hover {
background: var(--surface-primary, #f8fafc);
}
.hamburger-line {
display: block;
width: 20px;
height: 2px;
background: var(--text-secondary, #64748b);
border-radius: 1px;
}
/* Desktop nav — hidden on mobile */
.desktop-nav {
display: none;
align-items: center;
gap: 0.125rem;
} }
.nav-link { .nav-link {
padding: 0.5rem 1rem; padding: 0.375rem 0.625rem;
font-size: 0.875rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary, #64748b); color: var(--text-secondary, #64748b);
text-decoration: none; text-decoration: none;
border-radius: 0.5rem; border-radius: 0.5rem;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
} }
.nav-link:hover { .nav-link:hover {
@@ -180,8 +348,8 @@
} }
.nav-button { .nav-button {
padding: 0.5rem 1rem; padding: 0.375rem 0.625rem;
font-size: 0.875rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary, #64748b); color: var(--text-secondary, #64748b);
background: transparent; background: transparent;
@@ -189,6 +357,8 @@
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
} }
.nav-button:hover { .nav-button:hover {
@@ -199,9 +369,9 @@
.logout-button { .logout-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.375rem;
padding: 0.5rem 1rem; padding: 0.375rem 0.625rem;
font-size: 0.875rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary, #64748b); color: var(--text-secondary, #64748b);
background: transparent; background: transparent;
@@ -209,6 +379,8 @@
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
} }
.logout-button:hover:not(:disabled) { .logout-button:hover:not(:disabled) {
@@ -221,6 +393,118 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Mobile overlay */
.mobile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
animation: fadeIn 0.2s ease-out;
}
/* Mobile drawer */
.mobile-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(320px, 85vw);
background: var(--surface-elevated, #fff);
z-index: 201;
display: flex;
flex-direction: column;
animation: slideInLeft 0.25s ease-out;
}
.mobile-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
.mobile-close {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
color: var(--text-secondary, #64748b);
border-radius: 0.5rem;
}
.mobile-close:hover {
background: var(--surface-primary, #f8fafc);
color: var(--text-primary, #1f2937);
}
.mobile-drawer-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem 0;
}
.mobile-role-switcher {
padding: 0.5rem 1.25rem 0.75rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
margin-bottom: 0.5rem;
}
.mobile-nav-link {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem 1.25rem;
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
text-decoration: none;
border: none;
background: none;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.15s;
}
.mobile-nav-link:hover {
background: var(--surface-primary, #f8fafc);
color: var(--text-primary, #1f2937);
}
.mobile-nav-link.active {
color: var(--accent-primary, #0ea5e9);
border-left-color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
}
.mobile-logout {
color: var(--color-alert, #ef4444);
}
.mobile-logout:hover {
background: #fef2f2;
}
.mobile-drawer-footer {
border-top: 1px solid var(--border-subtle, #e2e8f0);
padding: 0.5rem 0;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
.spinner { .spinner {
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -246,18 +530,33 @@
} }
} }
@media (min-width: 768px) { @media (min-width: 1200px) {
.header-content { .header-content {
flex-wrap: nowrap;
height: 64px; height: 64px;
padding: 0;
gap: 0; gap: 0;
} }
.header-nav { .mobile-section-label {
width: auto; display: none;
flex-wrap: nowrap; }
justify-content: flex-start;
.hamburger-button {
display: none;
}
.desktop-nav {
display: flex;
}
.nav-link,
.nav-button {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.logout-button {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
} }
.admin-main { .admin-main {

View File

@@ -1,173 +0,0 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth/auth.svelte';
let classCount = $state<number | null>(null);
let subjectCount = $state<number | null>(null);
$effect(() => {
loadStats();
});
async function loadStats() {
const base = getApiBaseUrl();
const [classesRes, subjectsRes] = await Promise.allSettled([
authenticatedFetch(`${base}/classes`),
authenticatedFetch(`${base}/subjects`)
]);
if (classesRes.status === 'fulfilled' && classesRes.value.ok) {
const data = await classesRes.value.json();
classCount = Array.isArray(data) ? data.length : (data['hydra:totalItems'] ?? null);
}
if (subjectsRes.status === 'fulfilled' && subjectsRes.value.ok) {
const data = await subjectsRes.value.json();
subjectCount = Array.isArray(data) ? data.length : (data['hydra:totalItems'] ?? null);
}
}
</script>
<svelte:head>
<title>Administration - Classeo</title>
</svelte:head>
<div class="admin-dashboard">
<header class="page-header">
<h1>Administration</h1>
<p class="subtitle">Configurez votre établissement</p>
</header>
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{classCount ?? ''}</span>
<span class="stat-label">Classes</span>
</div>
<div class="stat-card">
<span class="stat-value">{subjectCount ?? ''}</span>
<span class="stat-label">Matières</span>
</div>
</div>
<div class="action-cards">
<a class="action-card" href="/admin/classes">
<span class="action-icon">🏫</span>
<span class="action-label">Classes</span>
<span class="action-hint">Créer et gérer les classes</span>
</a>
<a class="action-card" href="/admin/subjects">
<span class="action-icon">📚</span>
<span class="action-label">Matières</span>
<span class="action-hint">Créer et gérer les matières</span>
</a>
<a class="action-card" href="/admin/academic-year/periods">
<span class="action-icon">📅</span>
<span class="action-label">Périodes scolaires</span>
<span class="action-hint">Trimestres ou semestres</span>
</a>
</div>
</div>
<style>
.admin-dashboard {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1f2937);
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--text-secondary, #64748b);
font-size: 0.875rem;
}
.stats-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 1rem 1.5rem;
background: var(--surface-elevated, #fff);
border: 1px solid var(--border-subtle, #e2e8f0);
border-radius: 0.75rem;
min-width: 100px;
flex: 1;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1f2937);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
}
.action-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.action-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
background: var(--surface-elevated, #fff);
border: 2px solid var(--border-subtle, #e2e8f0);
border-radius: 0.75rem;
text-decoration: none;
color: inherit;
transition: all 0.2s;
}
.action-card:hover {
border-color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.action-icon {
font-size: 2rem;
}
.action-label {
font-weight: 600;
color: var(--text-primary, #374151);
}
.action-hint {
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
text-align: center;
}
@media (min-width: 640px) {
.stats-row {
flex-wrap: nowrap;
}
.stat-card {
flex: 0 1 auto;
}
}
</style>

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
redirect(302, '/admin/users');
}

View File

@@ -352,11 +352,11 @@
<tbody> <tbody>
{#each assignments as assignment (assignment.id)} {#each assignments as assignment (assignment.id)}
<tr> <tr>
<td class="teacher-cell"> <td data-label="Enseignant" class="teacher-cell">
<span class="teacher-name">{assignment.teacherFirstName} {assignment.teacherLastName}</span> <span class="teacher-name">{assignment.teacherFirstName} {assignment.teacherLastName}</span>
</td> </td>
<td>{assignment.className}</td> <td data-label="Classe">{assignment.className}</td>
<td> <td data-label="Matière">
{#if getSubjectColor(assignment.subjectId)} {#if getSubjectColor(assignment.subjectId)}
<span <span
class="subject-badge" class="subject-badge"
@@ -368,13 +368,13 @@
{assignment.subjectName} {assignment.subjectName}
{/if} {/if}
</td> </td>
<td> <td data-label="Statut">
<span class="status-badge status-active">Active</span> <span class="status-badge status-active">Active</span>
</td> </td>
<td class="date-cell"> <td data-label="Depuis le" class="date-cell">
{new Date(assignment.startDate).toLocaleDateString('fr-FR')} {new Date(assignment.startDate).toLocaleDateString('fr-FR')}
</td> </td>
<td class="actions-cell"> <td data-label="Actions" class="actions-cell">
<button <button
class="btn-remove" class="btn-remove"
onclick={() => openDeleteModal(assignment)} onclick={() => openDeleteModal(assignment)}
@@ -916,12 +916,58 @@
color: #6b7280; color: #6b7280;
} }
@media (min-width: 768px) { @media (max-width: 767px) {
.assignments-table th:nth-child(4), .table-container {
.assignments-table td:nth-child(4), background: transparent;
.assignments-table th:nth-child(5), border: none;
.assignments-table td:nth-child(5) { }
display: table-cell;
.assignments-table thead {
display: none;
}
.assignments-table tbody tr {
display: block;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
margin-bottom: 0.75rem;
padding: 0.25rem 0;
}
.assignments-table tr:hover td {
background: transparent;
}
.assignments-table td {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.5rem 1rem;
border-bottom: none;
text-align: right;
}
.assignments-table td::before {
content: attr(data-label);
font-weight: 600;
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: left;
margin-right: 1rem;
flex-shrink: 0;
}
.teacher-cell {
white-space: normal;
}
.actions-cell {
justify-content: flex-end;
padding-top: 0.75rem;
border-top: 1px solid #f3f4f6;
} }
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -569,11 +569,11 @@
<tbody> <tbody>
{#each users as user (user.id)} {#each users as user (user.id)}
<tr> <tr>
<td class="user-name-cell"> <td data-label="Nom" class="user-name-cell">
<span class="user-fullname">{user.firstName} {user.lastName}</span> <span class="user-fullname">{user.firstName} {user.lastName}</span>
</td> </td>
<td class="user-email">{user.email}</td> <td data-label="Email" class="user-email">{user.email}</td>
<td> <td data-label="Rôle">
<div class="role-badges"> <div class="role-badges">
{#if user.roles && user.roles.length > 0} {#if user.roles && user.roles.length > 0}
{#each user.roles as roleValue} {#each user.roles as roleValue}
@@ -584,7 +584,7 @@
{/if} {/if}
</div> </div>
</td> </td>
<td> <td data-label="Statut">
<span class="status-badge {getStatutClass(user.statut, user.invitationExpiree)}"> <span class="status-badge {getStatutClass(user.statut, user.invitationExpiree)}">
{getStatutDisplay(user.statut, user.invitationExpiree)} {getStatutDisplay(user.statut, user.invitationExpiree)}
</span> </span>
@@ -594,8 +594,8 @@
</span> </span>
{/if} {/if}
</td> </td>
<td class="date-cell">{formatDate(user.invitedAt)}</td> <td data-label="Invitation" class="date-cell">{formatDate(user.invitedAt)}</td>
<td class="actions-cell"> <td data-label="Actions" class="actions-cell">
{#if user.id !== getCurrentUserId()} {#if user.id !== getCurrentUserId()}
<button <button
class="btn-secondary btn-sm" class="btn-secondary btn-sm"
@@ -1196,11 +1196,6 @@
background: #f9fafb; background: #f9fafb;
} }
.users-table th:nth-child(5),
.users-table td:nth-child(5) {
display: none;
}
.user-name-cell { .user-name-cell {
white-space: nowrap; white-space: nowrap;
} }
@@ -1481,10 +1476,61 @@
.form-row { .form-row {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
}
.users-table th:nth-child(5), @media (max-width: 767px) {
.users-table td:nth-child(5) { .users-table-container {
display: table-cell; background: transparent;
border: none;
}
.users-table thead {
display: none;
}
.users-table tbody tr {
display: block;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
margin-bottom: 0.75rem;
padding: 0.25rem 0;
}
.users-table tr:hover td {
background: transparent;
}
.users-table td {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.5rem 1rem;
border-bottom: none;
text-align: right;
}
.users-table td::before {
content: attr(data-label);
font-weight: 600;
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: left;
margin-right: 1rem;
flex-shrink: 0;
}
.user-name-cell {
white-space: normal;
}
.actions-cell {
justify-content: flex-end;
flex-wrap: wrap;
padding-top: 0.75rem;
border-top: 1px solid #f3f4f6;
} }
} }
</style> </style>