feat: Affectation des enseignants aux classes et matières

Permet aux administrateurs d'associer un enseignant à une classe pour une
matière donnée au sein d'une année scolaire. Cette brique est nécessaire
pour construire les emplois du temps et les carnets de notes par la suite.

Le modèle impose l'unicité du triplet enseignant × classe × matière par
année scolaire, avec réactivation automatique d'une affectation retirée
plutôt que duplication. L'isolation multi-tenant est garantie au niveau
du repository (findById/get filtrent par tenant_id).
This commit is contained in:
2026-02-13 20:22:39 +01:00
parent 73a473ec93
commit 88e7f319db
61 changed files with 6484 additions and 52 deletions

View File

@@ -149,6 +149,13 @@ services:
App\Administration\Domain\Repository\GradingConfigurationRepository: App\Administration\Domain\Repository\GradingConfigurationRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository
# Teacher Assignment (Story 2.8 - Affectation enseignants)
App\Administration\Domain\Repository\TeacherAssignmentRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherAssignmentRepository
App\Administration\Application\Port\TeacherAssignmentChecker:
alias: App\Administration\Infrastructure\Service\RepositoryTeacherAssignmentChecker
# 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,55 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Creates the teacher_assignments table for Story 2.8.
*
* Models the ternary relationship Teacher × Class × Subject, scoped by
* academic year and tenant. Supports multi-subject per teacher and
* co-teaching (multiple teachers per subject/class).
*/
final class Version20260212100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create teacher_assignments table (Story 2.8)';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS teacher_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
teacher_id UUID NOT NULL,
school_class_id UUID NOT NULL,
subject_id UUID NOT NULL,
academic_year_id UUID NOT NULL,
start_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
end_date TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(teacher_id, school_class_id, subject_id, academic_year_id)
)
SQL);
$this->addSql('CREATE INDEX idx_teacher_assignments_tenant ON teacher_assignments(tenant_id)');
$this->addSql('CREATE INDEX idx_teacher_assignments_teacher ON teacher_assignments(teacher_id)');
$this->addSql('CREATE INDEX idx_teacher_assignments_class ON teacher_assignments(school_class_id)');
$this->addSql('CREATE INDEX idx_teacher_assignments_year ON teacher_assignments(academic_year_id)');
$this->addSql('CREATE INDEX idx_teacher_assignments_teacher_tenant_status ON teacher_assignments(teacher_id, tenant_id, status)');
$this->addSql('CREATE INDEX idx_teacher_assignments_class_tenant_status ON teacher_assignments(school_class_id, tenant_id, status)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS teacher_assignments');
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds tenant_id to the UNIQUE constraint on teacher_assignments.
*
* The original constraint (teacher_id, school_class_id, subject_id, academic_year_id)
* allowed the same tuple to exist across different tenants, which is correct,
* but did not scope uniqueness per tenant. Including tenant_id ensures that
* uniqueness is enforced within each tenant boundary.
*/
final class Version20260212200000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add tenant_id to teacher_assignments UNIQUE constraint for multi-tenant safety';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE teacher_assignments
DROP CONSTRAINT IF EXISTS teacher_assignments_teacher_id_school_class_id_subject_id_acad_key
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE teacher_assignments
ADD CONSTRAINT teacher_assignments_tenant_teacher_class_subject_year_key
UNIQUE (tenant_id, teacher_id, school_class_id, subject_id, academic_year_id)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE teacher_assignments
DROP CONSTRAINT IF EXISTS teacher_assignments_tenant_teacher_class_subject_year_key
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE teacher_assignments
ADD CONSTRAINT teacher_assignments_teacher_id_school_class_id_subject_id_acad_key
UNIQUE (teacher_id, school_class_id, subject_id, academic_year_id)
SQL);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignTeacher;
final readonly class AssignTeacherCommand
{
public function __construct(
public string $tenantId,
public string $teacherId,
public string $classId,
public string $subjectId,
public string $academicYearId,
) {
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignTeacher;
use App\Administration\Domain\Exception\AffectationDejaExistanteException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class AssignTeacherHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private Clock $clock,
) {
}
public function __invoke(AssignTeacherCommand $command): TeacherAssignment
{
$tenantId = TenantId::fromString($command->tenantId);
$teacherId = UserId::fromString($command->teacherId);
$classId = ClassId::fromString($command->classId);
$subjectId = SubjectId::fromString($command->subjectId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
// Valider l'existence des entités référencées (throws NotFoundException)
$this->userRepository->get($teacherId);
$this->classRepository->get($classId);
$this->subjectRepository->get($subjectId);
// Vérifier l'unicité du triplet enseignant × classe × matière
$existing = $this->assignmentRepository->findByTeacherClassSubject(
$teacherId,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
if ($existing !== null) {
throw AffectationDejaExistanteException::pourTriple($teacherId, $classId, $subjectId);
}
// Vérifier si une affectation retirée existe pour ce même triplet.
// Si oui, la réactiver au lieu d'en créer une nouvelle (évite la violation
// de la contrainte UNIQUE qui couvre tous les statuts).
$removed = $this->assignmentRepository->findRemovedByTeacherClassSubject(
$teacherId,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
if ($removed !== null) {
$removed->reactiver($this->clock->now());
$this->assignmentRepository->save($removed);
return $removed;
}
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacherId,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
createdAt: $this->clock->now(),
);
$this->assignmentRepository->save($assignment);
return $assignment;
}
}

View File

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

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RemoveAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class RemoveAssignmentHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private Clock $clock,
) {
}
public function __invoke(RemoveAssignmentCommand $command): TeacherAssignment
{
$assignmentId = TeacherAssignmentId::fromString($command->assignmentId);
$tenantId = TenantId::fromString($command->tenantId);
$assignment = $this->assignmentRepository->get($assignmentId, $tenantId);
$assignment->retirer($this->clock->now());
$this->assignmentRepository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
/**
* Port pour vérifier si un enseignant est affecté à une classe/matière.
*
* Utilisé par les Voters (GradeVoter, HomeworkVoter) pour autoriser
* la saisie de notes ou devoirs uniquement aux enseignants affectés.
*
* @see AC2: Peut saisir notes pour cette matière dans cette classe
* @see AC4: Bloquer saisie si affectation retirée
*/
interface TeacherAssignmentChecker
{
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): bool;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignment;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAssignmentHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
public function __invoke(GetAssignmentQuery $query): ?TeacherAssignmentDto
{
$assignment = $this->assignmentRepository->findByTeacherClassSubject(
UserId::fromString($query->teacherId),
ClassId::fromString($query->classId),
SubjectId::fromString($query->subjectId),
AcademicYearId::fromString($query->academicYearId),
TenantId::fromString($query->tenantId),
);
if ($assignment === null) {
return null;
}
return TeacherAssignmentDto::fromDomain($assignment);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignment;
final readonly class GetAssignmentQuery
{
public function __construct(
public string $teacherId,
public string $classId,
public string $subjectId,
public string $academicYearId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignmentsForTeacher;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAssignmentsForTeacherHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
/**
* @return TeacherAssignmentDto[]
*/
public function __invoke(GetAssignmentsForTeacherQuery $query): array
{
$teacherId = UserId::fromString($query->teacherId);
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findActiveByTeacher($teacherId, $tenantId);
return array_map(TeacherAssignmentDto::fromDomain(...), $assignments);
}
}

View File

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

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignmentsForTeacher;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use DateTimeImmutable;
final readonly class TeacherAssignmentDto
{
public function __construct(
public string $id,
public string $teacherId,
public string $classId,
public string $subjectId,
public string $academicYearId,
public string $status,
public DateTimeImmutable $startDate,
public ?DateTimeImmutable $endDate,
public DateTimeImmutable $createdAt,
) {
}
public static function fromDomain(TeacherAssignment $assignment): self
{
return new self(
id: (string) $assignment->id,
teacherId: (string) $assignment->teacherId,
classId: (string) $assignment->classId,
subjectId: (string) $assignment->subjectId,
academicYearId: (string) $assignment->academicYearId,
status: $assignment->status->value,
startDate: $assignment->startDate,
endDate: $assignment->endDate,
createdAt: $assignment->createdAt,
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetTeachersForClass;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetTeachersForClassHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
/**
* @return TeacherAssignmentDto[]
*/
public function __invoke(GetTeachersForClassQuery $query): array
{
$classId = ClassId::fromString($query->classId);
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findActiveByClass($classId, $tenantId);
return array_map(TeacherAssignmentDto::fromDomain(...), $assignments);
}
}

View File

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

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class AffectationRetiree implements DomainEvent
{
public function __construct(
public TeacherAssignmentId $assignmentId,
public UserId $teacherId,
public ClassId $classId,
public SubjectId $subjectId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->assignmentId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class EnseignantAffecte implements DomainEvent
{
public function __construct(
public TeacherAssignmentId $assignmentId,
public UserId $teacherId,
public ClassId $classId,
public SubjectId $subjectId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->assignmentId->value;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class AffectationDejaExistanteException extends DomainException
{
public static function pourTriple(UserId $teacherId, ClassId $classId, SubjectId $subjectId): self
{
return new self(sprintf(
'L\'enseignant "%s" est déjà affecté à la classe "%s" pour la matière "%s".',
$teacherId,
$classId,
$subjectId,
));
}
}

View File

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

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\TeacherAssignment;
enum AssignmentStatus: string
{
case ACTIVE = 'active';
case REMOVED = 'removed';
public function estActive(): bool
{
return $this === self::ACTIVE;
}
public function label(): string
{
return match ($this) {
self::ACTIVE => 'Active',
self::REMOVED => 'Retirée',
};
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\TeacherAssignment;
use App\Administration\Domain\Event\AffectationRetiree;
use App\Administration\Domain\Event\EnseignantAffecte;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root représentant l'affectation d'un enseignant à une classe et une matière.
*
* Relation ternaire : Teacher × Class × Subject, scopée par année scolaire.
* Supporte le multi-matière (un enseignant, plusieurs matières par classe)
* et le co-enseignement (plusieurs enseignants, même matière dans une classe).
*
* @see FR78: Affecter enseignant à classe et matière
*/
final class TeacherAssignment extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) TeacherAssignmentId $id,
public private(set) TenantId $tenantId,
public private(set) UserId $teacherId,
public private(set) ClassId $classId,
public private(set) SubjectId $subjectId,
public private(set) AcademicYearId $academicYearId,
public private(set) DateTimeImmutable $startDate,
public private(set) ?DateTimeImmutable $endDate,
public private(set) AssignmentStatus $status,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
public static function creer(
TenantId $tenantId,
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
DateTimeImmutable $createdAt,
): self {
$assignment = new self(
id: TeacherAssignmentId::generate(),
tenantId: $tenantId,
teacherId: $teacherId,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
startDate: $createdAt,
endDate: null,
status: AssignmentStatus::ACTIVE,
createdAt: $createdAt,
);
$assignment->recordEvent(new EnseignantAffecte(
assignmentId: $assignment->id,
teacherId: $assignment->teacherId,
classId: $assignment->classId,
subjectId: $assignment->subjectId,
occurredOn: $createdAt,
));
return $assignment;
}
/**
* Retire l'affectation. Les notes existantes sont conservées (historique),
* mais l'enseignant ne peut plus en ajouter.
*/
public function retirer(DateTimeImmutable $at): void
{
if ($this->status === AssignmentStatus::REMOVED) {
return;
}
$this->status = AssignmentStatus::REMOVED;
$this->endDate = $at;
$this->updatedAt = $at;
$this->recordEvent(new AffectationRetiree(
assignmentId: $this->id,
teacherId: $this->teacherId,
classId: $this->classId,
subjectId: $this->subjectId,
occurredOn: $at,
));
}
/**
* Réactive une affectation précédemment retirée.
*
* Permet de ré-affecter un enseignant au même triplet sans violer
* la contrainte d'unicité en base.
*/
public function reactiver(DateTimeImmutable $at): void
{
if ($this->status === AssignmentStatus::ACTIVE) {
return;
}
$this->status = AssignmentStatus::ACTIVE;
$this->endDate = null;
$this->startDate = $at;
$this->updatedAt = $at;
$this->recordEvent(new EnseignantAffecte(
assignmentId: $this->id,
teacherId: $this->teacherId,
classId: $this->classId,
subjectId: $this->subjectId,
occurredOn: $at,
));
}
public function estActive(): bool
{
return $this->status->estActive();
}
/**
* Reconstitue une TeacherAssignment depuis le stockage.
*
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
TeacherAssignmentId $id,
TenantId $tenantId,
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
AssignmentStatus $status,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$assignment = new self(
id: $id,
tenantId: $tenantId,
teacherId: $teacherId,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
startDate: $startDate,
endDate: $endDate,
status: $status,
createdAt: $createdAt,
);
$assignment->updatedAt = $updatedAt;
return $assignment;
}
}

View File

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

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
interface TeacherAssignmentRepository
{
public function save(TeacherAssignment $assignment): void;
/**
* @throws \App\Administration\Domain\Exception\AffectationNotFoundException
*/
public function get(TeacherAssignmentId $id, TenantId $tenantId): TeacherAssignment;
public function findById(TeacherAssignmentId $id, TenantId $tenantId): ?TeacherAssignment;
/**
* Vérifie si une affectation active existe déjà pour ce triplet enseignant × classe × matière.
*/
public function findByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment;
/**
* Recherche une affectation retirée pour ce triplet enseignant × classe × matière.
*
* Utilisé pour réactiver une affectation au lieu d'en créer une nouvelle,
* évitant ainsi une violation de la contrainte d'unicité.
*/
public function findRemovedByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment;
/**
* Retourne toutes les affectations actives d'un enseignant.
*
* @return TeacherAssignment[]
*/
public function findActiveByTeacher(
UserId $teacherId,
TenantId $tenantId,
): array;
/**
* Retourne tous les enseignants affectés à une classe.
*
* @return TeacherAssignment[]
*/
public function findActiveByClass(
ClassId $classId,
TenantId $tenantId,
): array;
}

View File

@@ -12,6 +12,7 @@ use App\Administration\Domain\Exception\ClasseDejaExistanteException;
use App\Administration\Domain\Exception\ClassNameInvalideException; use App\Administration\Domain\Exception\ClassNameInvalideException;
use App\Administration\Infrastructure\Api\Resource\ClassResource; use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter; use App\Administration\Infrastructure\Security\ClassVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use Override; use Override;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
@@ -34,6 +35,7 @@ final readonly class CreateClassProcessor implements ProcessorInterface
private TenantContext $tenantContext, private TenantContext $tenantContext,
private MessageBusInterface $eventBus, private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker, private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) { ) {
} }
@@ -53,11 +55,11 @@ final readonly class CreateClassProcessor implements ProcessorInterface
$tenantId = (string) $this->tenantContext->getCurrentTenantId(); $tenantId = (string) $this->tenantContext->getCurrentTenantId();
// TODO: Récupérer school_id et academic_year_id depuis le contexte utilisateur // TODO: Récupérer school_id depuis le contexte utilisateur
// quand les modules Schools et AcademicYears seront implémentés. // quand le module Schools sera implémenté.
// Pour l'instant, on utilise des UUIDs déterministes basés sur le tenant.
$schoolId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString(); $schoolId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString();
$academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString(); $academicYearId = $this->academicYearResolver->resolve('current')
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
try { try {
$command = new CreateClassCommand( $command = new CreateClassCommand(

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\AssignTeacher\AssignTeacherCommand;
use App\Administration\Application\Command\AssignTeacher\AssignTeacherHandler;
use App\Administration\Domain\Exception\AffectationDejaExistanteException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\SubjectNotFoundException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
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<TeacherAssignmentResource, TeacherAssignmentResource>
*/
final readonly class CreateTeacherAssignmentProcessor implements ProcessorInterface
{
public function __construct(
private AssignTeacherHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/**
* @param TeacherAssignmentResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TeacherAssignmentResource
{
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::CREATE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer une affectation.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$rawAcademicYearId = $this->academicYearResolver->resolve($data->academicYearId ?? 'current');
if ($rawAcademicYearId === null) {
throw new BadRequestHttpException('Identifiant d\'année scolaire invalide.');
}
try {
$command = new AssignTeacherCommand(
tenantId: $tenantId,
teacherId: $data->teacherId ?? '',
classId: $data->classId ?? '',
subjectId: $data->subjectId ?? '',
academicYearId: $rawAcademicYearId,
);
$assignment = ($this->handler)($command);
foreach ($assignment->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return TeacherAssignmentResource::fromDomain($assignment);
} catch (AffectationDejaExistanteException $e) {
throw new ConflictHttpException($e->getMessage());
} catch (UserNotFoundException|ClasseNotFoundException|SubjectNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\RemoveAssignment\RemoveAssignmentCommand;
use App\Administration\Application\Command\RemoveAssignment\RemoveAssignmentHandler;
use App\Administration\Domain\Exception\AffectationNotFoundException;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
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<TeacherAssignmentResource, null>
*/
final readonly class RemoveTeacherAssignmentProcessor implements ProcessorInterface
{
public function __construct(
private RemoveAssignmentHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @param TeacherAssignmentResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::DELETE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à retirer une affectation.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string|null $assignmentId */
$assignmentId = $uriVariables['id'] ?? null;
if ($assignmentId === null) {
throw new NotFoundHttpException('Affectation non trouvée.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$command = new RemoveAssignmentCommand(
assignmentId: $assignmentId,
tenantId: $tenantId,
);
$assignment = ($this->handler)($command);
foreach ($assignment->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return null;
} catch (AffectationNotFoundException|InvalidUuidStringException) {
throw new NotFoundHttpException('Affectation non trouvée.');
}
}
}

View File

@@ -10,9 +10,9 @@ use App\Administration\Application\Query\GetClasses\GetClassesHandler;
use App\Administration\Application\Query\GetClasses\GetClassesQuery; use App\Administration\Application\Query\GetClasses\GetClassesQuery;
use App\Administration\Infrastructure\Api\Resource\ClassResource; use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter; use App\Administration\Infrastructure\Security\ClassVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use Override; use Override;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -28,6 +28,7 @@ final readonly class ClassCollectionProvider implements ProviderInterface
private GetClassesHandler $handler, private GetClassesHandler $handler,
private TenantContext $tenantContext, private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker, private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) { ) {
} }
@@ -48,9 +49,10 @@ final readonly class ClassCollectionProvider implements ProviderInterface
$tenantId = (string) $this->tenantContext->getCurrentTenantId(); $tenantId = (string) $this->tenantContext->getCurrentTenantId();
// TODO: Récupérer academic_year_id depuis le contexte utilisateur $academicYearId = $this->academicYearResolver->resolve('current') ?? '';
// quand le module AcademicYears sera implémenté. if ($academicYearId === '') {
$academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString(); return [];
}
$query = new GetClassesQuery( $query = new GetClassesQuery(
tenantId: $tenantId, tenantId: $tenantId,

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<TeacherAssignmentResource>
*/
final readonly class TeacherAssignmentItemProvider implements ProviderInterface
{
public function __construct(
private TeacherAssignmentRepository $repository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?TeacherAssignmentResource
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::DELETE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer cette affectation.');
}
/** @var string $id */
$id = $uriVariables['id'] ?? '';
try {
$assignment = $this->repository->findById(
TeacherAssignmentId::fromString($id),
$this->tenantContext->getCurrentTenantId(),
);
} catch (InvalidArgumentException) {
return null;
}
if ($assignment === null) {
return null;
}
return TeacherAssignmentResource::fromDomain($assignment);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetTeachersForClass\GetTeachersForClassHandler;
use App\Administration\Application\Query\GetTeachersForClass\GetTeachersForClassQuery;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<TeacherAssignmentResource>
*/
final readonly class TeacherAssignmentsByClassProvider implements ProviderInterface
{
public function __construct(
private GetTeachersForClassHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @return TeacherAssignmentResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les enseignants de cette classe.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $classId */
$classId = $uriVariables['classId'] ?? '';
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$query = new GetTeachersForClassQuery(
classId: $classId,
tenantId: $tenantId,
);
$dtos = ($this->handler)($query);
return array_map(TeacherAssignmentResource::fromDto(...), $dtos);
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('Identifiant classe invalide.', $e);
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetAssignmentsForTeacher\GetAssignmentsForTeacherHandler;
use App\Administration\Application\Query\GetAssignmentsForTeacher\GetAssignmentsForTeacherQuery;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<TeacherAssignmentResource>
*/
final readonly class TeacherAssignmentsByTeacherProvider implements ProviderInterface
{
public function __construct(
private GetAssignmentsForTeacherHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @return TeacherAssignmentResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $teacherId */
$teacherId = $uriVariables['teacherId'] ?? '';
// Passer une ressource avec le teacherId pour que le voter puisse
// vérifier que l'enseignant ne consulte que ses propres affectations.
$subjectForVoter = new TeacherAssignmentResource();
$subjectForVoter->teacherId = $teacherId;
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::VIEW, $subjectForVoter)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les affectations.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$query = new GetAssignmentsForTeacherQuery(
teacherId: $teacherId,
tenantId: $tenantId,
);
$dtos = ($this->handler)($query);
return array_map(TeacherAssignmentResource::fromDto(...), $dtos);
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('Identifiant enseignant invalide.', $e);
}
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Post;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Infrastructure\Api\Processor\CreateTeacherAssignmentProcessor;
use App\Administration\Infrastructure\Api\Processor\RemoveTeacherAssignmentProcessor;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentItemProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByClassProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByTeacherProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la gestion des affectations enseignants.
*
* @see Story 2.8 - Affectation Enseignants aux Classes et Matières
* @see FR78 - Affecter enseignant à classe et matière
*/
#[ApiResource(
shortName: 'TeacherAssignment',
operations: [
new GetCollection(
uriTemplate: '/teachers/{teacherId}/assignments',
uriVariables: [
'teacherId' => new Link(
fromClass: self::class,
identifiers: ['teacherId'],
),
],
provider: TeacherAssignmentsByTeacherProvider::class,
name: 'get_teacher_assignments',
),
new GetCollection(
uriTemplate: '/classes/{classId}/teachers',
uriVariables: [
'classId' => new Link(
fromClass: self::class,
identifiers: ['classId'],
),
],
provider: TeacherAssignmentsByClassProvider::class,
name: 'get_class_teachers',
),
new Post(
uriTemplate: '/teacher-assignments',
processor: CreateTeacherAssignmentProcessor::class,
validationContext: ['groups' => ['Default', 'create']],
name: 'create_teacher_assignment',
),
new Delete(
uriTemplate: '/teacher-assignments/{id}',
provider: TeacherAssignmentItemProvider::class,
processor: RemoveTeacherAssignmentProcessor::class,
name: 'remove_teacher_assignment',
),
],
)]
final class TeacherAssignmentResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
#[Assert\NotBlank(message: 'L\'identifiant de l\'enseignant est requis.', groups: ['create'])]
public ?string $teacherId = null;
#[Assert\NotBlank(message: 'L\'identifiant de la classe est requis.', groups: ['create'])]
public ?string $classId = null;
#[Assert\NotBlank(message: 'L\'identifiant de la matière est requis.', groups: ['create'])]
public ?string $subjectId = null;
#[Assert\NotBlank(message: 'L\'identifiant de l\'année scolaire est requis.', groups: ['create'])]
public ?string $academicYearId = null;
public ?string $status = null;
public ?DateTimeImmutable $startDate = null;
public ?DateTimeImmutable $endDate = null;
public ?DateTimeImmutable $createdAt = null;
public static function fromDomain(TeacherAssignment $assignment): self
{
$resource = new self();
$resource->id = (string) $assignment->id;
$resource->teacherId = (string) $assignment->teacherId;
$resource->classId = (string) $assignment->classId;
$resource->subjectId = (string) $assignment->subjectId;
$resource->academicYearId = (string) $assignment->academicYearId;
$resource->status = $assignment->status->value;
$resource->startDate = $assignment->startDate;
$resource->endDate = $assignment->endDate;
$resource->createdAt = $assignment->createdAt;
return $resource;
}
public static function fromDto(TeacherAssignmentDto $dto): self
{
$resource = new self();
$resource->id = $dto->id;
$resource->teacherId = $dto->teacherId;
$resource->classId = $dto->classId;
$resource->subjectId = $dto->subjectId;
$resource->academicYearId = $dto->academicYearId;
$resource->status = $dto->status;
$resource->startDate = $dto->startDate;
$resource->endDate = $dto->endDate;
$resource->createdAt = $dto->createdAt;
return $resource;
}
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\AffectationDejaExistanteException;
use App\Administration\Domain\Exception\AffectationNotFoundException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\AssignmentStatus;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Override;
final readonly class DoctrineTeacherAssignmentRepository implements TeacherAssignmentRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(TeacherAssignment $assignment): void
{
try {
$this->connection->executeStatement(
'INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, start_date, end_date, status, created_at, updated_at)
VALUES (:id, :tenant_id, :teacher_id, :school_class_id, :subject_id, :academic_year_id, :start_date, :end_date, :status, :created_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $assignment->id,
'tenant_id' => (string) $assignment->tenantId,
'teacher_id' => (string) $assignment->teacherId,
'school_class_id' => (string) $assignment->classId,
'subject_id' => (string) $assignment->subjectId,
'academic_year_id' => (string) $assignment->academicYearId,
'start_date' => $assignment->startDate->format(DateTimeImmutable::ATOM),
'end_date' => $assignment->endDate?->format(DateTimeImmutable::ATOM),
'status' => $assignment->status->value,
'created_at' => $assignment->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $assignment->updatedAt->format(DateTimeImmutable::ATOM),
],
);
} catch (UniqueConstraintViolationException) {
throw AffectationDejaExistanteException::pourTriple(
$assignment->teacherId,
$assignment->classId,
$assignment->subjectId,
);
}
}
#[Override]
public function get(TeacherAssignmentId $id, TenantId $tenantId): TeacherAssignment
{
$assignment = $this->findById($id, $tenantId);
if ($assignment === null) {
throw AffectationNotFoundException::withId($id);
}
return $assignment;
}
#[Override]
public function findById(TeacherAssignmentId $id, TenantId $tenantId): ?TeacherAssignment
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM teacher_assignments 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 findByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment {
$row = $this->connection->fetchAssociative(
'SELECT * FROM teacher_assignments
WHERE tenant_id = :tenant_id
AND teacher_id = :teacher_id
AND school_class_id = :school_class_id
AND subject_id = :subject_id
AND academic_year_id = :academic_year_id
AND status = :status',
[
'tenant_id' => (string) $tenantId,
'teacher_id' => (string) $teacherId,
'school_class_id' => (string) $classId,
'subject_id' => (string) $subjectId,
'academic_year_id' => (string) $academicYearId,
'status' => AssignmentStatus::ACTIVE->value,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findRemovedByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment {
$row = $this->connection->fetchAssociative(
'SELECT * FROM teacher_assignments
WHERE tenant_id = :tenant_id
AND teacher_id = :teacher_id
AND school_class_id = :school_class_id
AND subject_id = :subject_id
AND academic_year_id = :academic_year_id
AND status = :status',
[
'tenant_id' => (string) $tenantId,
'teacher_id' => (string) $teacherId,
'school_class_id' => (string) $classId,
'subject_id' => (string) $subjectId,
'academic_year_id' => (string) $academicYearId,
'status' => AssignmentStatus::REMOVED->value,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findActiveByTeacher(
UserId $teacherId,
TenantId $tenantId,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_assignments
WHERE teacher_id = :teacher_id
AND tenant_id = :tenant_id
AND status = :status
ORDER BY created_at ASC',
[
'teacher_id' => (string) $teacherId,
'tenant_id' => (string) $tenantId,
'status' => AssignmentStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findActiveByClass(
ClassId $classId,
TenantId $tenantId,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_assignments
WHERE school_class_id = :school_class_id
AND tenant_id = :tenant_id
AND status = :status
ORDER BY created_at ASC',
[
'school_class_id' => (string) $classId,
'tenant_id' => (string) $tenantId,
'status' => AssignmentStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): TeacherAssignment
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $teacherId */
$teacherId = $row['teacher_id'];
/** @var string $classId */
$classId = $row['school_class_id'];
/** @var string $subjectId */
$subjectId = $row['subject_id'];
/** @var string $academicYearId */
$academicYearId = $row['academic_year_id'];
/** @var string $startDate */
$startDate = $row['start_date'];
/** @var string|null $endDate */
$endDate = $row['end_date'];
/** @var string $status */
$status = $row['status'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return TeacherAssignment::reconstitute(
id: TeacherAssignmentId::fromString($id),
tenantId: TenantId::fromString($tenantId),
teacherId: UserId::fromString($teacherId),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString($subjectId),
academicYearId: AcademicYearId::fromString($academicYearId),
startDate: new DateTimeImmutable($startDate),
endDate: $endDate !== null ? new DateTimeImmutable($endDate) : null,
status: AssignmentStatus::from($status),
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\AffectationNotFoundException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\AssignmentStatus;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryTeacherAssignmentRepository implements TeacherAssignmentRepository
{
/** @var array<string, TeacherAssignment> */
private array $byId = [];
#[Override]
public function save(TeacherAssignment $assignment): void
{
$this->byId[(string) $assignment->id] = $assignment;
}
#[Override]
public function get(TeacherAssignmentId $id, TenantId $tenantId): TeacherAssignment
{
$assignment = $this->findById($id, $tenantId);
if ($assignment === null) {
throw AffectationNotFoundException::withId($id);
}
return $assignment;
}
#[Override]
public function findById(TeacherAssignmentId $id, TenantId $tenantId): ?TeacherAssignment
{
$assignment = $this->byId[(string) $id] ?? null;
if ($assignment !== null && !$assignment->tenantId->equals($tenantId)) {
return null;
}
return $assignment;
}
#[Override]
public function findByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment {
foreach ($this->byId as $assignment) {
if ($assignment->tenantId->equals($tenantId)
&& $assignment->teacherId->equals($teacherId)
&& $assignment->classId->equals($classId)
&& $assignment->subjectId->equals($subjectId)
&& $assignment->academicYearId->equals($academicYearId)
&& $assignment->status === AssignmentStatus::ACTIVE
) {
return $assignment;
}
}
return null;
}
#[Override]
public function findRemovedByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment {
foreach ($this->byId as $assignment) {
if ($assignment->tenantId->equals($tenantId)
&& $assignment->teacherId->equals($teacherId)
&& $assignment->classId->equals($classId)
&& $assignment->subjectId->equals($subjectId)
&& $assignment->academicYearId->equals($academicYearId)
&& $assignment->status === AssignmentStatus::REMOVED
) {
return $assignment;
}
}
return null;
}
#[Override]
public function findActiveByTeacher(
UserId $teacherId,
TenantId $tenantId,
): array {
$result = [];
foreach ($this->byId as $assignment) {
if ($assignment->teacherId->equals($teacherId)
&& $assignment->tenantId->equals($tenantId)
&& $assignment->status === AssignmentStatus::ACTIVE
) {
$result[] = $assignment;
}
}
return $result;
}
#[Override]
public function findActiveByClass(
ClassId $classId,
TenantId $tenantId,
): array {
$result = [];
foreach ($this->byId as $assignment) {
if ($assignment->classId->equals($classId)
&& $assignment->tenantId->equals($tenantId)
&& $assignment->status === AssignmentStatus::ACTIVE
) {
$result[] = $assignment;
}
}
return $result;
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
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 affectations enseignants.
*
* Règles d'accès :
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
* - ENSEIGNANT : lecture seule (ses propres affectations)
* - VIE_SCOLAIRE, SECRETARIAT : lecture seule
* - ELEVE et PARENT : pas d'accès direct
*
* @extends Voter<string, TeacherAssignment|TeacherAssignmentResource>
*/
final class TeacherAssignmentVoter extends Voter
{
public const string VIEW = 'TEACHER_ASSIGNMENT_VIEW';
public const string CREATE = 'TEACHER_ASSIGNMENT_CREATE';
public const string DELETE = 'TEACHER_ASSIGNMENT_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 TeacherAssignment || $subject instanceof TeacherAssignmentResource;
}
#[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
{
// Admins et personnel administratif : accès complet en lecture
if ($this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
Role::VIE_SCOLAIRE->value,
Role::SECRETARIAT->value,
])) {
return true;
}
// Enseignant : lecture seule de ses propres affectations
if ($this->hasAnyRole($roles, [Role::PROF->value])) {
return $this->isOwnResource($user, $subject);
}
return false;
}
/**
* Vérifie que la ressource appartient à l'enseignant connecté.
*/
private function isOwnResource(SecurityUser $user, mixed $subject): bool
{
if ($subject instanceof TeacherAssignment) {
return (string) $subject->teacherId === $user->userId();
}
if ($subject instanceof TeacherAssignmentResource) {
return $subject->teacherId === $user->userId();
}
// Pas de sujet (collection sans filtre) : refuser par défaut
return false;
}
/**
* @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,41 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use App\Administration\Application\Port\TeacherAssignmentChecker;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final readonly class RepositoryTeacherAssignmentChecker implements TeacherAssignmentChecker
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
#[Override]
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): bool {
$assignment = $this->assignmentRepository->findByTeacherClassSubject(
$teacherId,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
return $assignment !== null;
}
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\AssignTeacher;
use App\Administration\Application\Command\AssignTeacher\AssignTeacherCommand;
use App\Administration\Application\Command\AssignTeacher\AssignTeacherHandler;
use App\Administration\Domain\Exception\AffectationDejaExistanteException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\SubjectNotFoundException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\TeacherAssignment\AssignmentStatus;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AssignTeacherHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string 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';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440050';
private InMemoryTeacherAssignmentRepository $assignmentRepository;
private InMemoryUserRepository $userRepository;
private InMemoryClassRepository $classRepository;
private InMemorySubjectRepository $subjectRepository;
private Clock $clock;
protected function setUp(): void
{
$this->assignmentRepository = new InMemoryTeacherAssignmentRepository();
$this->userRepository = new InMemoryUserRepository();
$this->classRepository = new InMemoryClassRepository();
$this->subjectRepository = new InMemorySubjectRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-12 10:00:00');
}
};
$this->seedTestData();
}
#[Test]
public function itCreatesAssignmentSuccessfully(): void
{
$handler = $this->createHandler();
$command = $this->createCommand();
$assignment = $handler($command);
self::assertNotEmpty((string) $assignment->id);
self::assertSame(AssignmentStatus::ACTIVE, $assignment->status);
self::assertTrue($assignment->estActive());
self::assertNull($assignment->endDate);
}
#[Test]
public function itPersistsAssignmentInRepository(): void
{
$handler = $this->createHandler();
$command = $this->createCommand();
$created = $handler($command);
$assignment = $this->assignmentRepository->get(
TeacherAssignmentId::fromString((string) $created->id),
TenantId::fromString(self::TENANT_ID),
);
self::assertSame(AssignmentStatus::ACTIVE, $assignment->status);
}
#[Test]
public function itThrowsExceptionWhenDuplicateAssignment(): void
{
$handler = $this->createHandler();
$command = $this->createCommand();
$handler($command);
$this->expectException(AffectationDejaExistanteException::class);
$handler($command);
}
#[Test]
public function itAllowsSameTeacherDifferentSubject(): void
{
$secondSubjectId = '550e8400-e29b-41d4-a716-446655440031';
$subject2 = Subject::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
schoolId: SchoolId::fromString(self::SCHOOL_ID),
name: new SubjectName('Français'),
code: new SubjectCode('FR'),
color: null,
createdAt: new DateTimeImmutable('2026-01-15'),
);
// Reconstitute with known ID for test predictability
$subject2 = Subject::reconstitute(
id: \App\Administration\Domain\Model\Subject\SubjectId::fromString($secondSubjectId),
tenantId: TenantId::fromString(self::TENANT_ID),
schoolId: SchoolId::fromString(self::SCHOOL_ID),
name: new SubjectName('Français'),
code: new SubjectCode('FR'),
color: null,
status: \App\Administration\Domain\Model\Subject\SubjectStatus::ACTIVE,
description: null,
createdAt: new DateTimeImmutable('2026-01-15'),
updatedAt: new DateTimeImmutable('2026-01-15'),
deletedAt: null,
);
$this->subjectRepository->save($subject2);
$handler = $this->createHandler();
$assignment1 = $handler($this->createCommand());
$assignment2 = $handler($this->createCommand(subjectId: $secondSubjectId));
self::assertFalse($assignment1->id->equals($assignment2->id));
}
#[Test]
public function itAllowsDifferentTeacherSameClassSubject(): void
{
$secondTeacherId = '550e8400-e29b-41d4-a716-446655440011';
$teacher2 = User::creer(
email: new Email('teacher2@example.com'),
role: Role::PROF,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15'),
);
// Reconstitute with known ID
$teacher2 = User::reconstitute(
id: \App\Administration\Domain\Model\User\UserId::fromString($secondTeacherId),
email: new Email('teacher2@example.com'),
roles: [Role::PROF],
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: \App\Administration\Domain\Model\User\StatutCompte::EN_ATTENTE,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15'),
hashedPassword: null,
activatedAt: null,
consentementParental: null,
);
$this->userRepository->save($teacher2);
$handler = $this->createHandler();
$assignment1 = $handler($this->createCommand());
$assignment2 = $handler($this->createCommand(teacherId: $secondTeacherId));
self::assertFalse($assignment1->id->equals($assignment2->id));
}
#[Test]
public function itThrowsWhenTeacherDoesNotExist(): void
{
$handler = $this->createHandler();
$this->expectException(UserNotFoundException::class);
$handler($this->createCommand(teacherId: '550e8400-e29b-41d4-a716-446655440099'));
}
#[Test]
public function itThrowsWhenClassDoesNotExist(): void
{
$handler = $this->createHandler();
$this->expectException(ClasseNotFoundException::class);
$handler($this->createCommand(classId: '550e8400-e29b-41d4-a716-446655440099'));
}
#[Test]
public function itThrowsWhenSubjectDoesNotExist(): void
{
$handler = $this->createHandler();
$this->expectException(SubjectNotFoundException::class);
$handler($this->createCommand(subjectId: '550e8400-e29b-41d4-a716-446655440099'));
}
private function seedTestData(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
// Create teacher
$teacher = User::reconstitute(
id: \App\Administration\Domain\Model\User\UserId::fromString(self::TEACHER_ID),
email: new Email('teacher@example.com'),
roles: [Role::PROF],
tenantId: $tenantId,
schoolName: 'École Test',
statut: \App\Administration\Domain\Model\User\StatutCompte::EN_ATTENTE,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15'),
hashedPassword: null,
activatedAt: null,
consentementParental: null,
);
$this->userRepository->save($teacher);
// Create class
$class = SchoolClass::reconstitute(
id: \App\Administration\Domain\Model\SchoolClass\ClassId::fromString(self::CLASS_ID),
tenantId: $tenantId,
schoolId: SchoolId::fromString(self::SCHOOL_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
name: new ClassName('6ème A'),
level: SchoolLevel::SIXIEME,
capacity: 30,
status: \App\Administration\Domain\Model\SchoolClass\ClassStatus::ACTIVE,
description: null,
createdAt: new DateTimeImmutable('2026-01-15'),
updatedAt: new DateTimeImmutable('2026-01-15'),
deletedAt: null,
);
$this->classRepository->save($class);
// Create subject
$subject = Subject::reconstitute(
id: \App\Administration\Domain\Model\Subject\SubjectId::fromString(self::SUBJECT_ID),
tenantId: $tenantId,
schoolId: SchoolId::fromString(self::SCHOOL_ID),
name: new SubjectName('Mathématiques'),
code: new SubjectCode('MATH'),
color: null,
status: \App\Administration\Domain\Model\Subject\SubjectStatus::ACTIVE,
description: null,
createdAt: new DateTimeImmutable('2026-01-15'),
updatedAt: new DateTimeImmutable('2026-01-15'),
deletedAt: null,
);
$this->subjectRepository->save($subject);
}
private function createHandler(): AssignTeacherHandler
{
return new AssignTeacherHandler(
$this->assignmentRepository,
$this->userRepository,
$this->classRepository,
$this->subjectRepository,
$this->clock,
);
}
private function createCommand(
?string $teacherId = null,
?string $classId = null,
?string $subjectId = null,
): AssignTeacherCommand {
return new AssignTeacherCommand(
tenantId: self::TENANT_ID,
teacherId: $teacherId ?? self::TEACHER_ID,
classId: $classId ?? self::CLASS_ID,
subjectId: $subjectId ?? self::SUBJECT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\RemoveAssignment;
use App\Administration\Application\Command\RemoveAssignment\RemoveAssignmentCommand;
use App\Administration\Application\Command\RemoveAssignment\RemoveAssignmentHandler;
use App\Administration\Domain\Exception\AffectationNotFoundException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\AssignmentStatus;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RemoveAssignmentHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private InMemoryTeacherAssignmentRepository $assignmentRepository;
private Clock $clock;
protected function setUp(): void
{
$this->assignmentRepository = new InMemoryTeacherAssignmentRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-01 10:00:00');
}
};
}
#[Test]
public function itRemovesAssignmentSuccessfully(): void
{
$assignment = $this->createAndSaveAssignment();
$handler = new RemoveAssignmentHandler($this->assignmentRepository, $this->clock);
$command = new RemoveAssignmentCommand(
assignmentId: (string) $assignment->id,
tenantId: self::TENANT_ID,
);
$result = $handler($command);
self::assertSame(AssignmentStatus::REMOVED, $result->status);
self::assertFalse($result->estActive());
self::assertNotNull($result->endDate);
}
#[Test]
public function itThrowsExceptionWhenAssignmentNotFound(): void
{
$handler = new RemoveAssignmentHandler($this->assignmentRepository, $this->clock);
$command = new RemoveAssignmentCommand(
assignmentId: '550e8400-e29b-41d4-a716-446655440099',
tenantId: self::TENANT_ID,
);
$this->expectException(AffectationNotFoundException::class);
$handler($command);
}
#[Test]
public function itThrowsExceptionWhenTenantMismatch(): void
{
$assignment = $this->createAndSaveAssignment();
$handler = new RemoveAssignmentHandler($this->assignmentRepository, $this->clock);
$command = new RemoveAssignmentCommand(
assignmentId: (string) $assignment->id,
tenantId: '550e8400-e29b-41d4-a716-446655440099',
);
$this->expectException(AffectationNotFoundException::class);
$handler($command);
}
#[Test]
public function itIsIdempotentWhenAlreadyRemoved(): void
{
$assignment = $this->createAndSaveAssignment();
$handler = new RemoveAssignmentHandler($this->assignmentRepository, $this->clock);
$command = new RemoveAssignmentCommand(
assignmentId: (string) $assignment->id,
tenantId: self::TENANT_ID,
);
$result1 = $handler($command);
$result2 = $handler($command);
self::assertSame(AssignmentStatus::REMOVED, $result2->status);
self::assertEquals($result1->endDate, $result2->endDate);
}
private function createAndSaveAssignment(): TeacherAssignment
{
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'),
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440040'),
createdAt: new DateTimeImmutable('2026-02-12 10:00:00'),
);
$this->assignmentRepository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetAssignment;
use App\Administration\Application\Query\GetAssignment\GetAssignmentHandler;
use App\Administration\Application\Query\GetAssignment\GetAssignmentQuery;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetAssignmentHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string 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';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private InMemoryTeacherAssignmentRepository $repository;
private GetAssignmentHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
$this->handler = new GetAssignmentHandler($this->repository);
}
#[Test]
public function returnsNullWhenNoAssignmentFound(): void
{
$result = ($this->handler)(new GetAssignmentQuery(
teacherId: self::TEACHER_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
tenantId: self::TENANT_ID,
));
self::assertNull($result);
}
#[Test]
public function returnsAssignmentDto(): void
{
$assignment = $this->createAndSaveAssignment();
$result = ($this->handler)(new GetAssignmentQuery(
teacherId: self::TEACHER_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
tenantId: self::TENANT_ID,
));
self::assertInstanceOf(TeacherAssignmentDto::class, $result);
self::assertSame((string) $assignment->id, $result->id);
self::assertSame(self::TEACHER_ID, $result->teacherId);
self::assertSame(self::CLASS_ID, $result->classId);
self::assertSame(self::SUBJECT_ID, $result->subjectId);
}
#[Test]
public function returnsNullForRemovedAssignment(): void
{
$assignment = $this->createAndSaveAssignment();
$assignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00'));
$this->repository->save($assignment);
$result = ($this->handler)(new GetAssignmentQuery(
teacherId: self::TEACHER_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
tenantId: self::TENANT_ID,
));
self::assertNull($result);
}
#[Test]
public function dtoContainsCorrectData(): void
{
$createdAt = new DateTimeImmutable('2026-02-10 10:00:00');
$this->createAndSaveAssignment();
$result = ($this->handler)(new GetAssignmentQuery(
teacherId: self::TEACHER_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
tenantId: self::TENANT_ID,
));
self::assertSame(self::ACADEMIC_YEAR_ID, $result->academicYearId);
self::assertSame('active', $result->status);
self::assertEquals($createdAt, $result->startDate);
self::assertNull($result->endDate);
self::assertEquals($createdAt, $result->createdAt);
}
private function createAndSaveAssignment(): TeacherAssignment
{
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
$this->repository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetAssignmentsForTeacher;
use App\Administration\Application\Query\GetAssignmentsForTeacher\GetAssignmentsForTeacherHandler;
use App\Administration\Application\Query\GetAssignmentsForTeacher\GetAssignmentsForTeacherQuery;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetAssignmentsForTeacherHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private InMemoryTeacherAssignmentRepository $repository;
private GetAssignmentsForTeacherHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
$this->handler = new GetAssignmentsForTeacherHandler($this->repository);
}
#[Test]
public function returnsEmptyWhenNoAssignments(): void
{
$result = ($this->handler)(new GetAssignmentsForTeacherQuery(
teacherId: self::TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertSame([], $result);
}
#[Test]
public function returnsActiveAssignmentsForTeacher(): void
{
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440030');
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440031');
$result = ($this->handler)(new GetAssignmentsForTeacherQuery(
teacherId: self::TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(2, $result);
self::assertContainsOnlyInstancesOf(TeacherAssignmentDto::class, $result);
}
#[Test]
public function excludesRemovedAssignments(): void
{
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440030');
$removed = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440031'),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
$removed->retirer(new DateTimeImmutable('2026-02-11 10:00:00'));
$this->repository->save($removed);
$result = ($this->handler)(new GetAssignmentsForTeacherQuery(
teacherId: self::TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
}
#[Test]
public function excludesAssignmentsFromDifferentTenant(): void
{
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440030');
$result = ($this->handler)(new GetAssignmentsForTeacherQuery(
teacherId: self::TEACHER_ID,
tenantId: '550e8400-e29b-41d4-a716-446655440099',
));
self::assertSame([], $result);
}
#[Test]
public function dtoContainsCorrectData(): void
{
$createdAt = new DateTimeImmutable('2026-02-10 10:00:00');
$subjectId = '550e8400-e29b-41d4-a716-446655440030';
$this->createAndSaveAssignment($subjectId);
$result = ($this->handler)(new GetAssignmentsForTeacherQuery(
teacherId: self::TEACHER_ID,
tenantId: self::TENANT_ID,
));
self::assertSame(self::TEACHER_ID, $result[0]->teacherId);
self::assertSame(self::CLASS_ID, $result[0]->classId);
self::assertSame($subjectId, $result[0]->subjectId);
self::assertSame('active', $result[0]->status);
self::assertEquals($createdAt, $result[0]->startDate);
self::assertNull($result[0]->endDate);
}
private function createAndSaveAssignment(string $subjectId): void
{
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString($subjectId),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
$this->repository->save($assignment);
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetTeachersForClass;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Application\Query\GetTeachersForClass\GetTeachersForClassHandler;
use App\Administration\Application\Query\GetTeachersForClass\GetTeachersForClassQuery;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetTeachersForClassHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private InMemoryTeacherAssignmentRepository $repository;
private GetTeachersForClassHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
$this->handler = new GetTeachersForClassHandler($this->repository);
}
#[Test]
public function returnsEmptyWhenNoTeachersAssigned(): void
{
$result = ($this->handler)(new GetTeachersForClassQuery(
classId: self::CLASS_ID,
tenantId: self::TENANT_ID,
));
self::assertSame([], $result);
}
#[Test]
public function returnsTeachersForClass(): void
{
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440010');
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440011');
$result = ($this->handler)(new GetTeachersForClassQuery(
classId: self::CLASS_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(2, $result);
self::assertContainsOnlyInstancesOf(TeacherAssignmentDto::class, $result);
}
#[Test]
public function excludesRemovedAssignments(): void
{
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440010');
$removed = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440011'),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
$removed->retirer(new DateTimeImmutable('2026-02-11 10:00:00'));
$this->repository->save($removed);
$result = ($this->handler)(new GetTeachersForClassQuery(
classId: self::CLASS_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
}
#[Test]
public function excludesAssignmentsFromDifferentTenant(): void
{
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440010');
$result = ($this->handler)(new GetTeachersForClassQuery(
classId: self::CLASS_ID,
tenantId: '550e8400-e29b-41d4-a716-446655440099',
));
self::assertSame([], $result);
}
#[Test]
public function dtoContainsCorrectData(): void
{
$teacherId = '550e8400-e29b-41d4-a716-446655440010';
$createdAt = new DateTimeImmutable('2026-02-10 10:00:00');
$this->createAndSaveAssignment($teacherId);
$result = ($this->handler)(new GetTeachersForClassQuery(
classId: self::CLASS_ID,
tenantId: self::TENANT_ID,
));
self::assertSame($teacherId, $result[0]->teacherId);
self::assertSame(self::CLASS_ID, $result[0]->classId);
self::assertSame('active', $result[0]->status);
self::assertEquals($createdAt, $result[0]->startDate);
self::assertNull($result[0]->endDate);
}
private function createAndSaveAssignment(string $teacherId): void
{
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString($teacherId),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
$this->repository->save($assignment);
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\TeacherAssignment;
use App\Administration\Domain\Event\AffectationRetiree;
use App\Administration\Domain\Event\EnseignantAffecte;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\AssignmentStatus;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TeacherAssignmentTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string 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';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
#[Test]
public function creerCreatesAssignmentWithActiveStatus(): void
{
$assignment = $this->createAssignment();
self::assertSame(AssignmentStatus::ACTIVE, $assignment->status);
self::assertTrue($assignment->estActive());
self::assertNull($assignment->endDate);
}
#[Test]
public function creerRecordsEnseignantAffecteEvent(): void
{
$assignment = $this->createAssignment();
$events = $assignment->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(EnseignantAffecte::class, $events[0]);
self::assertSame($assignment->id, $events[0]->assignmentId);
self::assertSame($assignment->teacherId, $events[0]->teacherId);
self::assertSame($assignment->classId, $events[0]->classId);
self::assertSame($assignment->subjectId, $events[0]->subjectId);
}
#[Test]
public function creerSetsAllProperties(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$classId = ClassId::fromString(self::CLASS_ID);
$subjectId = SubjectId::fromString(self::SUBJECT_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$createdAt = new DateTimeImmutable('2026-02-12 10:00:00');
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacherId,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
createdAt: $createdAt,
);
self::assertTrue($assignment->tenantId->equals($tenantId));
self::assertTrue($assignment->teacherId->equals($teacherId));
self::assertTrue($assignment->classId->equals($classId));
self::assertTrue($assignment->subjectId->equals($subjectId));
self::assertTrue($assignment->academicYearId->equals($academicYearId));
self::assertEquals($createdAt, $assignment->startDate);
self::assertEquals($createdAt, $assignment->createdAt);
self::assertEquals($createdAt, $assignment->updatedAt);
self::assertNull($assignment->endDate);
}
#[Test]
public function retirerChangesStatusAndRecordsEvent(): void
{
$assignment = $this->createAssignment();
$assignment->pullDomainEvents();
$at = new DateTimeImmutable('2026-03-01 10:00:00');
$assignment->retirer($at);
self::assertSame(AssignmentStatus::REMOVED, $assignment->status);
self::assertFalse($assignment->estActive());
self::assertEquals($at, $assignment->endDate);
self::assertEquals($at, $assignment->updatedAt);
$events = $assignment->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(AffectationRetiree::class, $events[0]);
self::assertSame($assignment->id, $events[0]->assignmentId);
self::assertSame($assignment->teacherId, $events[0]->teacherId);
self::assertSame($assignment->classId, $events[0]->classId);
self::assertSame($assignment->subjectId, $events[0]->subjectId);
}
#[Test]
public function retirerAlreadyRemovedAssignmentDoesNothing(): void
{
$assignment = $this->createAssignment();
$assignment->retirer(new DateTimeImmutable('2026-03-01 10:00:00'));
$assignment->pullDomainEvents();
$originalEndDate = $assignment->endDate;
$assignment->retirer(new DateTimeImmutable('2026-03-02 10:00:00'));
self::assertEquals($originalEndDate, $assignment->endDate);
self::assertEmpty($assignment->pullDomainEvents());
}
#[Test]
public function reconstituteRestoresAllProperties(): void
{
$id = TeacherAssignmentId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$classId = ClassId::fromString(self::CLASS_ID);
$subjectId = SubjectId::fromString(self::SUBJECT_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$startDate = new DateTimeImmutable('2026-02-12 10:00:00');
$endDate = new DateTimeImmutable('2026-03-01 10:00:00');
$status = AssignmentStatus::REMOVED;
$createdAt = new DateTimeImmutable('2026-02-12 10:00:00');
$updatedAt = new DateTimeImmutable('2026-03-01 10:00:00');
$assignment = TeacherAssignment::reconstitute(
id: $id,
tenantId: $tenantId,
teacherId: $teacherId,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
startDate: $startDate,
endDate: $endDate,
status: $status,
createdAt: $createdAt,
updatedAt: $updatedAt,
);
self::assertTrue($assignment->id->equals($id));
self::assertTrue($assignment->tenantId->equals($tenantId));
self::assertTrue($assignment->teacherId->equals($teacherId));
self::assertTrue($assignment->classId->equals($classId));
self::assertTrue($assignment->subjectId->equals($subjectId));
self::assertTrue($assignment->academicYearId->equals($academicYearId));
self::assertEquals($startDate, $assignment->startDate);
self::assertEquals($endDate, $assignment->endDate);
self::assertSame($status, $assignment->status);
self::assertEquals($createdAt, $assignment->createdAt);
self::assertEquals($updatedAt, $assignment->updatedAt);
self::assertEmpty($assignment->pullDomainEvents());
}
private function createAssignment(): TeacherAssignment
{
return TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-12 10:00:00'),
);
}
}

View File

@@ -11,6 +11,7 @@ use App\Administration\Infrastructure\Api\Processor\CreateClassProcessor;
use App\Administration\Infrastructure\Api\Resource\ClassResource; use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository; use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Administration\Infrastructure\Security\ClassVoter; use App\Administration\Infrastructure\Security\ClassVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Clock; use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantConfig; use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
@@ -154,11 +155,19 @@ final class CreateClassProcessorTest extends TestCase
->with(ClassVoter::CREATE) ->with(ClassVoter::CREATE)
->willReturn($authorized); ->willReturn($authorized);
$resolvedTenantContext = $tenantContext ?? $this->tenantContext;
$academicYearResolver = new CurrentAcademicYearResolver(
$resolvedTenantContext,
$this->clock,
);
return new CreateClassProcessor( return new CreateClassProcessor(
$handler, $handler,
$tenantContext ?? $this->tenantContext, $resolvedTenantContext,
$eventBus, $eventBus,
$authorizationChecker, $authorizationChecker,
$academicYearResolver,
); );
} }
} }

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Post;
use App\Administration\Application\Command\AssignTeacher\AssignTeacherHandler;
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\SchoolClass\SchoolLevel;
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\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\Api\Processor\CreateTeacherAssignmentProcessor;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
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\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class CreateTeacherAssignmentProcessorTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string 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';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440050';
private InMemoryTeacherAssignmentRepository $repository;
private InMemoryUserRepository $userRepository;
private InMemoryClassRepository $classRepository;
private InMemorySubjectRepository $subjectRepository;
private TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
$this->userRepository = new InMemoryUserRepository();
$this->classRepository = new InMemoryClassRepository();
$this->subjectRepository = new InMemorySubjectRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-10 10:00:00');
}
};
$this->tenantContext = new TenantContext();
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_ID),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://test',
));
$this->seedTestData();
}
private function seedTestData(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$this->userRepository->save(User::reconstitute(
id: UserId::fromString(self::TEACHER_ID),
email: new Email('teacher@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->classRepository->save(SchoolClass::reconstitute(
id: ClassId::fromString(self::CLASS_ID),
tenantId: $tenantId,
schoolId: SchoolId::fromString(self::SCHOOL_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
name: new ClassName('6ème A'),
level: SchoolLevel::SIXIEME,
capacity: 30,
status: ClassStatus::ACTIVE,
description: null,
createdAt: new DateTimeImmutable('2026-01-15'),
updatedAt: new DateTimeImmutable('2026-01-15'),
deletedAt: null,
));
$this->subjectRepository->save(Subject::reconstitute(
id: SubjectId::fromString(self::SUBJECT_ID),
tenantId: $tenantId,
schoolId: SchoolId::fromString(self::SCHOOL_ID),
name: new SubjectName('Mathématiques'),
code: new SubjectCode('MATH'),
color: null,
status: SubjectStatus::ACTIVE,
description: null,
createdAt: new DateTimeImmutable('2026-01-15'),
updatedAt: new DateTimeImmutable('2026-01-15'),
deletedAt: null,
));
}
#[Test]
public function createsAssignmentSuccessfully(): void
{
$processor = $this->createProcessor();
$data = new TeacherAssignmentResource();
$data->teacherId = self::TEACHER_ID;
$data->classId = self::CLASS_ID;
$data->subjectId = self::SUBJECT_ID;
$data->academicYearId = self::ACADEMIC_YEAR_ID;
$result = $processor->process($data, new Post());
self::assertNotNull($result->id);
self::assertSame(self::TEACHER_ID, $result->teacherId);
self::assertSame(self::CLASS_ID, $result->classId);
self::assertSame(self::SUBJECT_ID, $result->subjectId);
self::assertSame('active', $result->status);
}
#[Test]
public function resolvesCurrentAcademicYear(): void
{
$processor = $this->createProcessor();
$data = new TeacherAssignmentResource();
$data->teacherId = self::TEACHER_ID;
$data->classId = self::CLASS_ID;
$data->subjectId = self::SUBJECT_ID;
$data->academicYearId = 'current';
$result = $processor->process($data, new Post());
self::assertNotNull($result->id);
self::assertNotNull($result->academicYearId);
}
#[Test]
public function throwsWhenNotAuthorized(): void
{
$processor = $this->createProcessor(authorized: false);
$data = new TeacherAssignmentResource();
$data->teacherId = self::TEACHER_ID;
$data->classId = self::CLASS_ID;
$data->subjectId = self::SUBJECT_ID;
$data->academicYearId = self::ACADEMIC_YEAR_ID;
$this->expectException(AccessDeniedHttpException::class);
$processor->process($data, new Post());
}
#[Test]
public function throwsWhenTenantNotSet(): void
{
$emptyTenantContext = new TenantContext();
$processor = $this->createProcessor(tenantContext: $emptyTenantContext);
$data = new TeacherAssignmentResource();
$data->teacherId = self::TEACHER_ID;
$data->classId = self::CLASS_ID;
$data->subjectId = self::SUBJECT_ID;
$data->academicYearId = self::ACADEMIC_YEAR_ID;
$this->expectException(UnauthorizedHttpException::class);
$processor->process($data, new Post());
}
#[Test]
public function throwsConflictWhenDuplicateAssignment(): void
{
$processor = $this->createProcessor();
$data = new TeacherAssignmentResource();
$data->teacherId = self::TEACHER_ID;
$data->classId = self::CLASS_ID;
$data->subjectId = self::SUBJECT_ID;
$data->academicYearId = self::ACADEMIC_YEAR_ID;
$processor->process($data, new Post());
$this->expectException(ConflictHttpException::class);
$processor->process($data, new Post());
}
#[Test]
public function throwsBadRequestForInvalidUuid(): void
{
$processor = $this->createProcessor();
$data = new TeacherAssignmentResource();
$data->teacherId = 'not-a-uuid';
$data->classId = self::CLASS_ID;
$data->subjectId = self::SUBJECT_ID;
$data->academicYearId = self::ACADEMIC_YEAR_ID;
$this->expectException(BadRequestHttpException::class);
$processor->process($data, new Post());
}
#[Test]
public function throwsBadRequestForInvalidAcademicYearId(): void
{
$processor = $this->createProcessor();
$data = new TeacherAssignmentResource();
$data->teacherId = self::TEACHER_ID;
$data->classId = self::CLASS_ID;
$data->subjectId = self::SUBJECT_ID;
$data->academicYearId = 'invalid-keyword';
$this->expectException(BadRequestHttpException::class);
$processor->process($data, new Post());
}
private function createProcessor(
bool $authorized = true,
?TenantContext $tenantContext = null,
): CreateTeacherAssignmentProcessor {
$handler = new AssignTeacherHandler(
$this->repository,
$this->userRepository,
$this->classRepository,
$this->subjectRepository,
$this->clock,
);
$eventBus = $this->createMock(MessageBusInterface::class);
$eventBus->method('dispatch')->willReturnCallback(
static fn (object $message) => new Envelope($message),
);
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')
->with(TeacherAssignmentVoter::CREATE)
->willReturn($authorized);
$resolvedTenantContext = $tenantContext ?? $this->tenantContext;
$academicYearResolver = new CurrentAcademicYearResolver(
$resolvedTenantContext,
$this->clock,
);
return new CreateTeacherAssignmentProcessor(
$handler,
$resolvedTenantContext,
$eventBus,
$authorizationChecker,
$academicYearResolver,
);
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Delete;
use App\Administration\Application\Command\RemoveAssignment\RemoveAssignmentHandler;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Api\Processor\RemoveTeacherAssignmentProcessor;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
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\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class RemoveTeacherAssignmentProcessorTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string 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';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private InMemoryTeacherAssignmentRepository $repository;
private TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-10 10:00:00');
}
};
$this->tenantContext = new TenantContext();
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_ID),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://test',
));
}
#[Test]
public function removesAssignmentSuccessfully(): void
{
$assignment = $this->createAndSaveAssignment();
$processor = $this->createProcessor();
$result = $processor->process(
new TeacherAssignmentResource(),
new Delete(),
uriVariables: ['id' => (string) $assignment->id],
);
self::assertNull($result);
}
#[Test]
public function throwsWhenNotAuthorized(): void
{
$assignment = $this->createAndSaveAssignment();
$processor = $this->createProcessor(authorized: false);
$this->expectException(AccessDeniedHttpException::class);
$processor->process(
new TeacherAssignmentResource(),
new Delete(),
uriVariables: ['id' => (string) $assignment->id],
);
}
#[Test]
public function throwsWhenTenantNotSet(): void
{
$assignment = $this->createAndSaveAssignment();
$emptyTenantContext = new TenantContext();
$processor = $this->createProcessor(tenantContext: $emptyTenantContext);
$this->expectException(UnauthorizedHttpException::class);
$processor->process(
new TeacherAssignmentResource(),
new Delete(),
uriVariables: ['id' => (string) $assignment->id],
);
}
#[Test]
public function throwsNotFoundWhenAssignmentDoesNotExist(): void
{
$processor = $this->createProcessor();
$this->expectException(NotFoundHttpException::class);
$processor->process(
new TeacherAssignmentResource(),
new Delete(),
uriVariables: ['id' => '550e8400-e29b-41d4-a716-446655440099'],
);
}
#[Test]
public function throwsNotFoundWhenNoIdProvided(): void
{
$processor = $this->createProcessor();
$this->expectException(NotFoundHttpException::class);
$processor->process(
new TeacherAssignmentResource(),
new Delete(),
);
}
#[Test]
public function throwsNotFoundForInvalidUuid(): void
{
$processor = $this->createProcessor();
$this->expectException(NotFoundHttpException::class);
$processor->process(
new TeacherAssignmentResource(),
new Delete(),
uriVariables: ['id' => 'not-a-uuid'],
);
}
private function createAndSaveAssignment(): TeacherAssignment
{
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
// Consommer les événements du domaine pour éviter les interférences
$assignment->pullDomainEvents();
$this->repository->save($assignment);
return $assignment;
}
private function createProcessor(
bool $authorized = true,
?TenantContext $tenantContext = null,
): RemoveTeacherAssignmentProcessor {
$handler = new RemoveAssignmentHandler($this->repository, $this->clock);
$eventBus = $this->createMock(MessageBusInterface::class);
$eventBus->method('dispatch')->willReturnCallback(
static fn (object $message) => new Envelope($message),
);
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')
->with(TeacherAssignmentVoter::DELETE)
->willReturn($authorized);
return new RemoveTeacherAssignmentProcessor(
$handler,
$tenantContext ?? $this->tenantContext,
$eventBus,
$authorizationChecker,
);
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Get;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentItemProvider;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
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\AuthorizationCheckerInterface;
final class TeacherAssignmentItemProviderTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string 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';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private InMemoryTeacherAssignmentRepository $repository;
private TenantContext $tenantContext;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
$this->tenantContext = new TenantContext();
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_ID),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://test',
));
}
#[Test]
public function returnsAssignmentById(): void
{
$assignment = $this->createAndSaveAssignment();
$provider = $this->createProvider();
$result = $provider->provide(
new Get(),
['id' => (string) $assignment->id],
);
self::assertInstanceOf(TeacherAssignmentResource::class, $result);
self::assertSame((string) $assignment->id, $result->id);
self::assertSame(self::TEACHER_ID, $result->teacherId);
}
#[Test]
public function returnsNullForUnknownId(): void
{
$provider = $this->createProvider();
$result = $provider->provide(
new Get(),
['id' => '550e8400-e29b-41d4-a716-446655440099'],
);
self::assertNull($result);
}
#[Test]
public function returnsNullForInvalidUuid(): void
{
$provider = $this->createProvider();
$result = $provider->provide(
new Get(),
['id' => 'not-a-uuid'],
);
self::assertNull($result);
}
#[Test]
public function throwsUnauthorizedWhenNoTenant(): void
{
$tenantContext = new TenantContext();
$provider = $this->createProvider(tenantContext: $tenantContext);
$this->expectException(UnauthorizedHttpException::class);
$provider->provide(
new Get(),
['id' => '550e8400-e29b-41d4-a716-446655440099'],
);
}
#[Test]
public function throwsAccessDeniedWhenNotAuthorized(): void
{
$provider = $this->createProvider(authorized: false);
$this->expectException(AccessDeniedHttpException::class);
$provider->provide(
new Get(),
['id' => '550e8400-e29b-41d4-a716-446655440099'],
);
}
private function createAndSaveAssignment(): TeacherAssignment
{
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
$this->repository->save($assignment);
return $assignment;
}
private function createProvider(
bool $authorized = true,
?TenantContext $tenantContext = null,
): TeacherAssignmentItemProvider {
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')
->with(TeacherAssignmentVoter::DELETE)
->willReturn($authorized);
return new TeacherAssignmentItemProvider(
$this->repository,
$tenantContext ?? $this->tenantContext,
$authorizationChecker,
);
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\GetCollection;
use App\Administration\Application\Query\GetTeachersForClass\GetTeachersForClassHandler;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByClassProvider;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
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\AuthorizationCheckerInterface;
final class TeacherAssignmentsByClassProviderTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private InMemoryTeacherAssignmentRepository $repository;
private TenantContext $tenantContext;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
$this->tenantContext = new TenantContext();
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_ID),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://test',
));
}
#[Test]
public function returnsTeachersForClass(): void
{
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440010');
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440011');
$provider = $this->createProvider();
$results = $provider->provide(
new GetCollection(),
['classId' => self::CLASS_ID],
);
self::assertCount(2, $results);
self::assertContainsOnlyInstancesOf(TeacherAssignmentResource::class, $results);
}
#[Test]
public function returnsEmptyArrayWhenNoTeachersAssigned(): void
{
$provider = $this->createProvider();
$results = $provider->provide(
new GetCollection(),
['classId' => self::CLASS_ID],
);
self::assertSame([], $results);
}
#[Test]
public function throwsBadRequestForInvalidUuid(): void
{
$provider = $this->createProvider();
$this->expectException(\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class);
$provider->provide(
new GetCollection(),
['classId' => 'not-a-uuid'],
);
}
#[Test]
public function throwsAccessDeniedWhenNotAuthorized(): void
{
$provider = $this->createProvider(authorized: false);
$this->expectException(AccessDeniedHttpException::class);
$provider->provide(
new GetCollection(),
['classId' => self::CLASS_ID],
);
}
#[Test]
public function throwsUnauthorizedWhenNoTenant(): void
{
$tenantContext = new TenantContext();
$provider = $this->createProvider(tenantContext: $tenantContext);
$this->expectException(UnauthorizedHttpException::class);
$provider->provide(
new GetCollection(),
['classId' => self::CLASS_ID],
);
}
private function createAndSaveAssignment(string $teacherId): void
{
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString($teacherId),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
$this->repository->save($assignment);
}
private function createProvider(
bool $authorized = true,
?TenantContext $tenantContext = null,
): TeacherAssignmentsByClassProvider {
$handler = new GetTeachersForClassHandler($this->repository);
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')
->with(TeacherAssignmentVoter::VIEW)
->willReturn($authorized);
return new TeacherAssignmentsByClassProvider(
$handler,
$tenantContext ?? $this->tenantContext,
$authorizationChecker,
);
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\GetCollection;
use App\Administration\Application\Query\GetAssignmentsForTeacher\GetAssignmentsForTeacherHandler;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByTeacherProvider;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
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\AuthorizationCheckerInterface;
final class TeacherAssignmentsByTeacherProviderTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private InMemoryTeacherAssignmentRepository $repository;
private TenantContext $tenantContext;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
$this->tenantContext = new TenantContext();
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_ID),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://test',
));
}
#[Test]
public function returnsAssignmentsForTeacher(): void
{
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440020');
$this->createAndSaveAssignment('550e8400-e29b-41d4-a716-446655440021');
$provider = $this->createProvider();
$results = $provider->provide(
new GetCollection(),
['teacherId' => self::TEACHER_ID],
);
self::assertCount(2, $results);
self::assertContainsOnlyInstancesOf(TeacherAssignmentResource::class, $results);
}
#[Test]
public function returnsEmptyArrayWhenNoAssignments(): void
{
$provider = $this->createProvider();
$results = $provider->provide(
new GetCollection(),
['teacherId' => self::TEACHER_ID],
);
self::assertSame([], $results);
}
#[Test]
public function throwsBadRequestForInvalidUuid(): void
{
$provider = $this->createProvider();
$this->expectException(\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class);
$provider->provide(
new GetCollection(),
['teacherId' => 'not-a-uuid'],
);
}
#[Test]
public function throwsAccessDeniedWhenNotAuthorized(): void
{
$provider = $this->createProvider(authorized: false);
$this->expectException(AccessDeniedHttpException::class);
$provider->provide(
new GetCollection(),
['teacherId' => self::TEACHER_ID],
);
}
#[Test]
public function throwsUnauthorizedWhenNoTenant(): void
{
$tenantContext = new TenantContext();
$provider = $this->createProvider(tenantContext: $tenantContext);
$this->expectException(UnauthorizedHttpException::class);
$provider->provide(
new GetCollection(),
['teacherId' => self::TEACHER_ID],
);
}
#[Test]
public function passesTeacherIdAsSubjectToVoter(): void
{
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
$authorizationChecker->expects(self::once())
->method('isGranted')
->with(
TeacherAssignmentVoter::VIEW,
self::callback(static function (TeacherAssignmentResource $subject): bool {
return $subject->teacherId === self::TEACHER_ID;
}),
)
->willReturn(true);
$handler = new GetAssignmentsForTeacherHandler($this->repository);
$provider = new TeacherAssignmentsByTeacherProvider(
$handler,
$this->tenantContext,
$authorizationChecker,
);
$provider->provide(
new GetCollection(),
['teacherId' => self::TEACHER_ID],
);
}
private function createAndSaveAssignment(string $classId): void
{
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
$this->repository->save($assignment);
}
private function createProvider(
bool $authorized = true,
?TenantContext $tenantContext = null,
): TeacherAssignmentsByTeacherProvider {
$handler = new GetAssignmentsForTeacherHandler($this->repository);
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')
->willReturn($authorized);
return new TeacherAssignmentsByTeacherProvider(
$handler,
$tenantContext ?? $this->tenantContext,
$authorizationChecker,
);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Resource;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TeacherAssignmentResourceTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string 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';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
#[Test]
public function fromDomainMapsCorrectly(): void
{
$createdAt = new DateTimeImmutable('2026-02-10 10:00:00');
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: $createdAt,
);
$resource = TeacherAssignmentResource::fromDomain($assignment);
self::assertSame((string) $assignment->id, $resource->id);
self::assertSame(self::TEACHER_ID, $resource->teacherId);
self::assertSame(self::CLASS_ID, $resource->classId);
self::assertSame(self::SUBJECT_ID, $resource->subjectId);
self::assertSame(self::ACADEMIC_YEAR_ID, $resource->academicYearId);
self::assertSame('active', $resource->status);
self::assertEquals($createdAt, $resource->startDate);
self::assertNull($resource->endDate);
self::assertEquals($createdAt, $resource->createdAt);
}
#[Test]
public function fromDomainMapsRemovedAssignment(): void
{
$createdAt = new DateTimeImmutable('2026-02-10 10:00:00');
$removedAt = new DateTimeImmutable('2026-02-11 14:00:00');
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: $createdAt,
);
$assignment->retirer($removedAt);
$resource = TeacherAssignmentResource::fromDomain($assignment);
self::assertSame('removed', $resource->status);
self::assertEquals($removedAt, $resource->endDate);
}
#[Test]
public function fromDtoMapsCorrectly(): void
{
$createdAt = new DateTimeImmutable('2026-02-10 10:00:00');
$dto = new TeacherAssignmentDto(
id: '550e8400-e29b-41d4-a716-446655440099',
teacherId: self::TEACHER_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
status: 'active',
startDate: $createdAt,
endDate: null,
createdAt: $createdAt,
);
$resource = TeacherAssignmentResource::fromDto($dto);
self::assertSame('550e8400-e29b-41d4-a716-446655440099', $resource->id);
self::assertSame(self::TEACHER_ID, $resource->teacherId);
self::assertSame(self::CLASS_ID, $resource->classId);
self::assertSame(self::SUBJECT_ID, $resource->subjectId);
self::assertSame(self::ACADEMIC_YEAR_ID, $resource->academicYearId);
self::assertSame('active', $resource->status);
self::assertEquals($createdAt, $resource->startDate);
self::assertNull($resource->endDate);
self::assertEquals($createdAt, $resource->createdAt);
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\AffectationNotFoundException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class InMemoryTeacherAssignmentRepositoryTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string 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';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private InMemoryTeacherAssignmentRepository $repository;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
}
#[Test]
public function saveAndGet(): void
{
$assignment = $this->createAssignment();
$this->repository->save($assignment);
$tenantId = TenantId::fromString(self::TENANT_ID);
$retrieved = $this->repository->get($assignment->id, $tenantId);
self::assertTrue($assignment->id->equals($retrieved->id));
}
#[Test]
public function getThrowsForUnknownId(): void
{
$this->expectException(AffectationNotFoundException::class);
$this->repository->get(
TeacherAssignmentId::generate(),
TenantId::fromString(self::TENANT_ID),
);
}
#[Test]
public function getThrowsForDifferentTenant(): void
{
$assignment = $this->createAssignment();
$this->repository->save($assignment);
$this->expectException(AffectationNotFoundException::class);
$this->repository->get(
$assignment->id,
TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
);
}
#[Test]
public function findByIdReturnsNullForUnknownId(): void
{
$result = $this->repository->findById(
TeacherAssignmentId::generate(),
TenantId::fromString(self::TENANT_ID),
);
self::assertNull($result);
}
#[Test]
public function findByIdReturnsNullForDifferentTenant(): void
{
$assignment = $this->createAssignment();
$this->repository->save($assignment);
$found = $this->repository->findById(
$assignment->id,
TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
);
self::assertNull($found);
}
#[Test]
public function findByIdReturnsAssignment(): void
{
$assignment = $this->createAssignment();
$this->repository->save($assignment);
$found = $this->repository->findById(
$assignment->id,
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($found);
self::assertTrue($assignment->id->equals($found->id));
}
#[Test]
public function findByTeacherClassSubjectReturnsActiveAssignment(): void
{
$assignment = $this->createAssignment();
$this->repository->save($assignment);
$found = $this->repository->findByTeacherClassSubject(
UserId::fromString(self::TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($found);
self::assertTrue($assignment->id->equals($found->id));
}
#[Test]
public function findByTeacherClassSubjectReturnsNullForRemovedAssignment(): void
{
$assignment = $this->createAssignment();
$assignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00'));
$this->repository->save($assignment);
$found = $this->repository->findByTeacherClassSubject(
UserId::fromString(self::TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNull($found);
}
#[Test]
public function findByTeacherClassSubjectReturnsNullForDifferentTenant(): void
{
$assignment = $this->createAssignment();
$this->repository->save($assignment);
$found = $this->repository->findByTeacherClassSubject(
UserId::fromString(self::TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
);
self::assertNull($found);
}
#[Test]
public function findRemovedByTeacherClassSubjectReturnsRemovedAssignment(): void
{
$assignment = $this->createAssignment();
$assignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00'));
$this->repository->save($assignment);
$found = $this->repository->findRemovedByTeacherClassSubject(
UserId::fromString(self::TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($found);
self::assertTrue($assignment->id->equals($found->id));
}
#[Test]
public function findRemovedByTeacherClassSubjectReturnsNullForActiveAssignment(): void
{
$assignment = $this->createAssignment();
$this->repository->save($assignment);
$found = $this->repository->findRemovedByTeacherClassSubject(
UserId::fromString(self::TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNull($found);
}
#[Test]
public function findActiveByTeacher(): void
{
$assignment1 = $this->createAssignment();
$assignment2 = $this->createAssignment(
subjectId: '550e8400-e29b-41d4-a716-446655440031',
);
$removedAssignment = $this->createAssignment(
subjectId: '550e8400-e29b-41d4-a716-446655440032',
);
$removedAssignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00'));
$this->repository->save($assignment1);
$this->repository->save($assignment2);
$this->repository->save($removedAssignment);
$result = $this->repository->findActiveByTeacher(
UserId::fromString(self::TEACHER_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertCount(2, $result);
}
#[Test]
public function findActiveByTeacherReturnsEmptyForDifferentTenant(): void
{
$assignment = $this->createAssignment();
$this->repository->save($assignment);
$result = $this->repository->findActiveByTeacher(
UserId::fromString(self::TEACHER_ID),
TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
);
self::assertEmpty($result);
}
#[Test]
public function findActiveByClass(): void
{
$assignment1 = $this->createAssignment();
$assignment2 = $this->createAssignment(
teacherId: '550e8400-e29b-41d4-a716-446655440011',
);
$removedAssignment = $this->createAssignment(
teacherId: '550e8400-e29b-41d4-a716-446655440012',
);
$removedAssignment->retirer(new DateTimeImmutable('2026-02-11 10:00:00'));
$this->repository->save($assignment1);
$this->repository->save($assignment2);
$this->repository->save($removedAssignment);
$result = $this->repository->findActiveByClass(
ClassId::fromString(self::CLASS_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertCount(2, $result);
}
#[Test]
public function findActiveByClassReturnsEmptyForDifferentTenant(): void
{
$assignment = $this->createAssignment();
$this->repository->save($assignment);
$result = $this->repository->findActiveByClass(
ClassId::fromString(self::CLASS_ID),
TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
);
self::assertEmpty($result);
}
private function createAssignment(
?string $teacherId = null,
?string $subjectId = null,
): TeacherAssignment {
return TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString($teacherId ?? self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString($subjectId ?? self::SUBJECT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
}
}

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
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 TeacherAssignmentVoterTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private TeacherAssignmentVoter $voter;
protected function setUp(): void
{
$this->voter = new TeacherAssignmentVoter();
}
#[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, [TeacherAssignmentVoter::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, [TeacherAssignmentVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
// --- VIEW ---
#[Test]
#[DataProvider('viewAllowedRolesProvider')]
public function itGrantsViewToStaffRoles(string $role): void
{
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, null, [TeacherAssignmentVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
/**
* @return iterable<string, array{string}>
*/
public static function viewAllowedRolesProvider(): iterable
{
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
yield 'ADMIN' => [Role::ADMIN->value];
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
}
#[Test]
public function itGrantsViewToTeacherForOwnResource(): void
{
$token = $this->tokenWithSecurityUser(Role::PROF->value, self::TEACHER_ID);
$resource = new TeacherAssignmentResource();
$resource->teacherId = self::TEACHER_ID;
$result = $this->voter->vote($token, $resource, [TeacherAssignmentVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itGrantsViewToTeacherForOwnDomainModel(): void
{
$token = $this->tokenWithSecurityUser(Role::PROF->value, self::TEACHER_ID);
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440040'),
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
);
$result = $this->voter->vote($token, $assignment, [TeacherAssignmentVoter::VIEW]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
#[Test]
public function itDeniesViewToTeacherForOtherTeacherResource(): void
{
$token = $this->tokenWithSecurityUser(Role::PROF->value, self::TEACHER_ID);
$resource = new TeacherAssignmentResource();
$resource->teacherId = '550e8400-e29b-41d4-a716-446655440099';
$result = $this->voter->vote($token, $resource, [TeacherAssignmentVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
public function itDeniesViewToTeacherWithNoSubject(): void
{
$token = $this->tokenWithSecurityUser(Role::PROF->value, self::TEACHER_ID);
$result = $this->voter->vote($token, null, [TeacherAssignmentVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
#[Test]
#[DataProvider('viewDeniedRolesProvider')]
public function itDeniesViewToNonStaffRoles(string $role): void
{
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, null, [TeacherAssignmentVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
/**
* @return iterable<string, array{string}>
*/
public static function viewDeniedRolesProvider(): iterable
{
yield 'PARENT' => [Role::PARENT->value];
yield 'ELEVE' => [Role::ELEVE->value];
}
// --- CREATE ---
#[Test]
#[DataProvider('adminRolesProvider')]
public function itGrantsCreateToAdminRoles(string $role): void
{
$token = $this->tokenWithSecurityUser($role);
$result = $this->voter->vote($token, null, [TeacherAssignmentVoter::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, [TeacherAssignmentVoter::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, [TeacherAssignmentVoter::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, [TeacherAssignmentVoter::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];
}
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,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Service;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Service\RepositoryTeacherAssignmentChecker;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RepositoryTeacherAssignmentCheckerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string 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';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
private InMemoryTeacherAssignmentRepository $repository;
private RepositoryTeacherAssignmentChecker $checker;
protected function setUp(): void
{
$this->repository = new InMemoryTeacherAssignmentRepository();
$this->checker = new RepositoryTeacherAssignmentChecker($this->repository);
}
#[Test]
public function estAffecteReturnsTrueWhenActiveAssignmentExists(): void
{
$this->createAndSaveAssignment();
self::assertTrue($this->checker->estAffecte(
UserId::fromString(self::TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString(self::TENANT_ID),
));
}
#[Test]
public function estAffecteReturnsFalseWhenNoAssignment(): void
{
self::assertFalse($this->checker->estAffecte(
UserId::fromString(self::TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString(self::TENANT_ID),
));
}
#[Test]
public function estAffecteReturnsFalseWhenAssignmentRemoved(): void
{
$assignment = $this->createAndSaveAssignment();
$assignment->retirer(new DateTimeImmutable('2026-03-01 10:00:00'));
$this->repository->save($assignment);
self::assertFalse($this->checker->estAffecte(
UserId::fromString(self::TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString(self::SUBJECT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString(self::TENANT_ID),
));
}
#[Test]
public function estAffecteReturnsFalseForDifferentSubject(): void
{
$this->createAndSaveAssignment();
self::assertFalse($this->checker->estAffecte(
UserId::fromString(self::TEACHER_ID),
ClassId::fromString(self::CLASS_ID),
SubjectId::fromString('550e8400-e29b-41d4-a716-446655440099'),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString(self::TENANT_ID),
));
}
private function createAndSaveAssignment(): TeacherAssignment
{
$assignment = TeacherAssignment::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
createdAt: new DateTimeImmutable('2026-02-12 10:00:00'),
);
$this->repository->save($assignment);
return $assignment;
}
}

View File

@@ -155,7 +155,7 @@ test.describe('Periods Management (Story 2.3)', () => {
await loginAsAdmin(page); await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`); await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
await expect(page.getByText(/trimestres/i)).toBeVisible({ timeout: 10000 }); await expect(page.getByText('Trimestres', { exact: true })).toBeVisible({ timeout: 10000 });
}); });
test('shows dates on each period card', async ({ page }) => { test('shows dates on each period card', async ({ page }) => {

View File

@@ -0,0 +1,263 @@
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-assignments-admin@example.com';
const ADMIN_PASSWORD = 'AssignmentsTest123';
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' }
);
}
/**
* Resolve deterministic UUIDs matching backend resolvers (SchoolIdResolver, CurrentAcademicYearResolver).
* Without these, SQL-inserted test data won't be found by the API.
*/
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: 10000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function waitForPageReady(page: import('@playwright/test').Page) {
await expect(
page.getByRole('heading', { name: /affectations enseignants/i })
).toBeVisible({ timeout: 15000 });
// Wait for data loading to finish (either empty state or table appears)
await expect(
page.locator('.empty-state, .assignments-table, .alert-error')
).toBeVisible({ timeout: 15000 });
}
async function openCreateDialog(page: import('@playwright/test').Page) {
// Use .first() because both the header and empty-state have a "Nouvelle affectation" button
const button = page.getByRole('button', { name: /nouvelle affectation/i }).first();
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
}
async function createAssignmentViaUI(page: import('@playwright/test').Page) {
await waitForPageReady(page);
await openCreateDialog(page);
await page.locator('#assignment-teacher').selectOption({ index: 1 });
await page.locator('#assignment-class').selectOption({ index: 1 });
await page.locator('#assignment-subject').selectOption({ index: 1 });
await page.getByRole('button', { name: /affecter/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
}
test.describe('Teacher Assignments (Story 2.8)', () => {
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-teacher-assign@example.com --password=TeacherTest123 --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
// Resolve deterministic IDs that match the backend resolvers
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-Assign-6A', '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-Assign-Maths', 'E2EMATH', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
});
test.beforeEach(async () => {
runCommand(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('assignments link appears in admin navigation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin`);
const navLink = page.getByRole('link', { name: /affectations/i });
await expect(navLink).toBeVisible({ timeout: 15000 });
});
test('can navigate to assignments page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await expect(page.getByRole('heading', { name: /affectations enseignants/i })).toBeVisible({ timeout: 15000 });
});
});
// ============================================================================
// Empty State
// ============================================================================
test.describe('Empty State', () => {
test('shows empty state when no assignments exist', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await waitForPageReady(page);
await expect(page.getByText(/aucune affectation/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC1: Create Assignment
// ============================================================================
test.describe('AC1: Create Assignment', () => {
test('can create a new assignment', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await waitForPageReady(page);
await openCreateDialog(page);
const teacherSelect = page.locator('#assignment-teacher');
await expect(teacherSelect).toBeVisible();
const teacherOptions = teacherSelect.locator('option');
const teacherCount = await teacherOptions.count();
expect(teacherCount).toBeGreaterThan(1);
await teacherSelect.selectOption({ index: 1 });
const classSelect = page.locator('#assignment-class');
await expect(classSelect).toBeVisible();
await classSelect.selectOption({ index: 1 });
const subjectSelect = page.locator('#assignment-subject');
await expect(subjectSelect).toBeVisible();
await subjectSelect.selectOption({ index: 1 });
await page.getByRole('button', { name: /affecter/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(/affectation créée/i)).toBeVisible({ timeout: 10000 });
const table = page.locator('.assignments-table');
await expect(table).toBeVisible({ timeout: 10000 });
const rows = table.locator('tbody tr');
const rowCount = await rows.count();
expect(rowCount).toBeGreaterThanOrEqual(1);
});
test('shows error when creating duplicate assignment', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
// Create first assignment
await createAssignmentViaUI(page);
// Attempt to create the same assignment again
await openCreateDialog(page);
await page.locator('#assignment-teacher').selectOption({ index: 1 });
await page.locator('#assignment-class').selectOption({ index: 1 });
await page.locator('#assignment-subject').selectOption({ index: 1 });
await page.getByRole('button', { name: /affecter/i }).click();
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
});
test('cancel closes the modal without creating', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await waitForPageReady(page);
await openCreateDialog(page);
await page.getByRole('button', { name: /annuler/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
});
});
// ============================================================================
// AC4: Remove Assignment
// ============================================================================
test.describe('AC4: Remove Assignment', () => {
test('can remove an assignment', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await createAssignmentViaUI(page);
const table = page.locator('.assignments-table');
await expect(table).toBeVisible({ timeout: 10000 });
const removeButton = page.locator('.btn-remove').first();
await removeButton.click();
const confirmDialog = page.getByRole('alertdialog');
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
await expect(page.getByText(/notes existantes seront conservées/i)).toBeVisible();
await confirmDialog.getByRole('button', { name: /retirer/i }).click();
await expect(confirmDialog).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(/affectation retirée/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC5: Class Detail - Teachers List
// ============================================================================
test.describe('AC5: Class Detail Teachers', () => {
test('class detail page shows teachers section', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await createAssignmentViaUI(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible({ timeout: 15000 });
const modifyButton = page.locator('.btn-secondary', { hasText: /modifier/i }).first();
await modifyButton.click();
await page.waitForURL(/\/admin\/classes\/[\w-]+/);
await expect(page.getByRole('heading', { name: /enseignants affectés/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('link', { name: /gérer les affectations/i })).toBeVisible();
});
});
});

View File

@@ -41,6 +41,11 @@
<span class="action-label">Gérer les matières</span> <span class="action-label">Gérer les matières</span>
<span class="action-hint">Créer et gérer</span> <span class="action-hint">Créer et gérer</span>
</a> </a>
<a class="action-card" href="/admin/assignments">
<span class="action-icon">📋</span>
<span class="action-label">Affectations</span>
<span class="action-hint">Enseignants et classes</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

@@ -192,8 +192,14 @@
{#if showAddModal} {#if showAddModal}
<div class="modal-overlay" onclick={() => { showAddModal = false; }} role="presentation"> <div class="modal-overlay" onclick={() => { showAddModal = false; }} role="presentation">
<!-- svelte-ignore a11y_interactive_supports_focus --> <div
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}> class="modal"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') { showAddModal = false; } }}
>
<header class="modal-header"> <header class="modal-header">
<h2>Ajouter un parent/tuteur</h2> <h2>Ajouter un parent/tuteur</h2>
<button class="modal-close" onclick={() => { showAddModal = false; }}>&times;</button> <button class="modal-close" onclick={() => { showAddModal = false; }}>&times;</button>

View File

@@ -1,17 +1,36 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { logout } from '$lib/auth/auth.svelte'; import { logout } from '$lib/auth/auth.svelte';
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte'; import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte'; import { fetchRoles, getRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
let { children } = $props(); let { children } = $props();
let isLoggingOut = $state(false); let isLoggingOut = $state(false);
let accessChecked = $state(false);
let hasAccess = $state(false);
// Load user roles on mount for multi-role context switching (FR5) const ADMIN_ROLES = [
$effect(() => { 'ROLE_SUPER_ADMIN',
untrack(() => fetchRoles()); 'ROLE_ADMIN',
'ROLE_VIE_SCOLAIRE',
'ROLE_SECRETARIAT'
];
// Load user roles and verify admin access
onMount(async () => {
await fetchRoles();
const userRoles = getRoles();
const isAdmin = userRoles.some((r) => ADMIN_ROLES.includes(r.value));
if (!isAdmin) {
goto('/dashboard');
return;
}
hasAccess = true;
accessChecked = true;
}); });
async function handleLogout() { async function handleLogout() {
@@ -37,9 +56,15 @@
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 isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy')); const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
</script> </script>
{#if !accessChecked}
<div class="loading-guard">
<div class="spinner"></div>
</div>
{:else if hasAccess}
<div class="admin-layout"> <div class="admin-layout">
<header class="admin-header"> <header class="admin-header">
<div class="header-content"> <div class="header-content">
@@ -52,6 +77,7 @@
<a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a> <a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a>
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a> <a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a> <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/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> <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>
@@ -73,8 +99,16 @@
</div> </div>
</main> </main>
</div> </div>
{/if}
<style> <style>
.loading-guard {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.admin-layout { .admin-layout {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;

View File

@@ -0,0 +1,884 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
// Types
interface TeacherAssignment {
id: string;
teacherId: string;
classId: string;
subjectId: string;
academicYearId: string;
status: string;
startDate: string;
endDate: string | null;
createdAt: string;
}
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
roles: string[];
}
interface SchoolClass {
id: string;
name: string;
level: string | null;
status: string;
}
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
status: string;
}
// State
let assignments = $state<TeacherAssignment[]>([]);
let teachers = $state<User[]>([]);
let classes = $state<SchoolClass[]>([]);
let subjects = $state<Subject[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Create modal
let showCreateModal = $state(false);
let selectedTeacherId = $state('');
let selectedClassId = $state('');
let selectedSubjectId = $state('');
let isSubmitting = $state(false);
// Delete state
let showDeleteModal = $state(false);
let assignmentToDelete = $state<TeacherAssignment | null>(null);
let isDeleting = $state(false);
// Load everything on mount
onMount(() => {
loadAll();
});
async function loadAll() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
// Load reference data in parallel
const [teachersRes, classesRes, subjectsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/users?role=ROLE_PROF`),
authenticatedFetch(`${apiUrl}/classes`),
authenticatedFetch(`${apiUrl}/subjects`)
]);
if (!teachersRes.ok) throw new Error('Erreur lors du chargement des enseignants');
if (!classesRes.ok) throw new Error('Erreur lors du chargement des classes');
if (!subjectsRes.ok) throw new Error('Erreur lors du chargement des matières');
const [teachersData, classesData, subjectsData] = await Promise.all([
teachersRes.json(),
classesRes.json(),
subjectsRes.json()
]);
teachers = extractCollection(teachersData);
classes = extractCollection(classesData);
subjects = extractCollection(subjectsData);
// Load assignments for each class in parallel
await loadAssignments();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
async function loadAssignments() {
const apiUrl = getApiBaseUrl();
if (classes.length === 0) {
assignments = [];
return;
}
const results = await Promise.all(
classes.map(async (cls) => {
try {
const res = await authenticatedFetch(`${apiUrl}/classes/${cls.id}/teachers`);
if (!res.ok) return [];
const data = await res.json();
return extractCollection(data) as TeacherAssignment[];
} catch {
return [];
}
})
);
assignments = results.flat();
}
function extractCollection<T>(data: Record<string, unknown>): T[] {
const hydra = data['hydra:member'];
if (Array.isArray(hydra)) return hydra as T[];
const member = data['member'];
if (Array.isArray(member)) return member as T[];
if (Array.isArray(data)) return data as T[];
return [];
}
function getTeacherName(teacherId: string): string {
const teacher = teachers.find((t) => t.id === teacherId);
return teacher ? `${teacher.firstName} ${teacher.lastName}` : teacherId;
}
function getClassName(classId: string): string {
const cls = classes.find((c) => c.id === classId);
return cls ? cls.name : classId;
}
function getSubjectName(subjectId: string): string {
const subject = subjects.find((s) => s.id === subjectId);
return subject ? subject.name : subjectId;
}
function getSubjectColor(subjectId: string): string | null {
const subject = subjects.find((s) => s.id === subjectId);
return subject?.color ?? null;
}
function openCreateModal() {
showCreateModal = true;
selectedTeacherId = '';
selectedClassId = '';
selectedSubjectId = '';
error = null;
}
function closeCreateModal() {
showCreateModal = false;
}
async function handleCreate() {
if (!selectedTeacherId || !selectedClassId || !selectedSubjectId) return;
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/teacher-assignments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teacherId: selectedTeacherId,
classId: selectedClassId,
subjectId: selectedSubjectId,
academicYearId: 'current'
})
});
if (!response.ok) {
const data = await response.json().catch(() => null);
const message =
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? `Erreur (${response.status})`;
throw new Error(message);
}
successMessage = 'Affectation créée avec succès';
closeCreateModal();
await loadAssignments();
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la création';
} finally {
isSubmitting = false;
}
}
function openDeleteModal(assignment: TeacherAssignment) {
assignmentToDelete = assignment;
showDeleteModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
assignmentToDelete = null;
}
async function handleDelete() {
if (!assignmentToDelete) return;
try {
isDeleting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/teacher-assignments/${assignmentToDelete.id}`,
{ method: 'DELETE' }
);
if (!response.ok) {
const data = await response.json().catch(() => null);
const message =
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? `Erreur (${response.status})`;
throw new Error(message);
}
successMessage = 'Affectation retirée avec succès';
closeDeleteModal();
await loadAssignments();
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la suppression';
} finally {
isDeleting = false;
}
}
// Only show active assignments
const activeAssignments = $derived(assignments.filter((a) => a.status === 'active'));
</script>
<svelte:head>
<title>Affectations enseignants - Classeo</title>
</svelte:head>
<div class="assignments-page">
<header class="page-header">
<div class="header-content">
<h1>Affectations enseignants</h1>
<p class="subtitle">Affectez les enseignants à leurs classes et matières</p>
</div>
<button class="btn-primary" onclick={openCreateModal}>
<span class="btn-icon">+</span>
Nouvelle affectation
</button>
</header>
{#if error}
<div class="alert alert-error">
<span class="alert-icon">!</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>x</button>
</div>
{/if}
{#if successMessage}
<div class="alert alert-success">
{successMessage}
</div>
{/if}
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement des affectations...</p>
</div>
{:else if activeAssignments.length === 0}
<div class="empty-state">
<span class="empty-icon">&#x1F4CB;</span>
<h2>Aucune affectation</h2>
<p>Commencez par affecter un enseignant à une classe et une matière</p>
<button class="btn-primary" onclick={openCreateModal}>Nouvelle affectation</button>
</div>
{:else}
<div class="table-container">
<table class="assignments-table">
<thead>
<tr>
<th>Enseignant</th>
<th>Classe</th>
<th>Matière</th>
<th>Statut</th>
<th>Depuis le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each activeAssignments as assignment (assignment.id)}
<tr>
<td class="teacher-cell">
<span class="teacher-name">{getTeacherName(assignment.teacherId)}</span>
</td>
<td>{getClassName(assignment.classId)}</td>
<td>
{#if getSubjectColor(assignment.subjectId)}
<span
class="subject-badge"
style="background-color: {getSubjectColor(assignment.subjectId)}; color: white"
>
{getSubjectName(assignment.subjectId)}
</span>
{:else}
{getSubjectName(assignment.subjectId)}
{/if}
</td>
<td>
<span class="status-badge status-active">Active</span>
</td>
<td class="date-cell">
{new Date(assignment.startDate).toLocaleDateString('fr-FR')}
</td>
<td class="actions-cell">
<button
class="btn-remove"
onclick={() => openDeleteModal(assignment)}
>
Retirer
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Create Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeCreateModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="create-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeCreateModal(); }}
>
<header class="modal-header">
<h2 id="create-modal-title">Nouvelle affectation</h2>
<button class="modal-close" onclick={closeCreateModal} aria-label="Fermer">x</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div class="form-group">
<label for="assignment-teacher">Enseignant *</label>
<select id="assignment-teacher" bind:value={selectedTeacherId} required>
<option value="">-- Sélectionner un enseignant --</option>
{#each teachers as teacher (teacher.id)}
<option value={teacher.id}>{teacher.firstName} {teacher.lastName}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="assignment-class">Classe *</label>
<select id="assignment-class" bind:value={selectedClassId} required>
<option value="">-- Sélectionner une classe --</option>
{#each classes as cls (cls.id)}
<option value={cls.id}>{cls.name}{cls.level ? ` (${cls.level})` : ''}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="assignment-subject">Matière *</label>
<select id="assignment-subject" bind:value={selectedSubjectId} required>
<option value="">-- Sélectionner une matière --</option>
{#each subjects as subject (subject.id)}
<option value={subject.id}>{subject.name} ({subject.code})</option>
{/each}
</select>
</div>
<div class="form-hint">
L'affectation sera créée pour l'année scolaire en cours.
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
Annuler
</button>
<button
type="submit"
class="btn-primary"
disabled={isSubmitting || !selectedTeacherId || !selectedClassId || !selectedSubjectId}
>
{#if isSubmitting}
Création...
{:else}
Affecter
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteModal && assignmentToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
<div
class="modal modal-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
>
<header class="modal-header modal-header-danger">
<h2 id="delete-modal-title">Retirer l'affectation</h2>
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">x</button>
</header>
<div class="modal-body">
<p id="delete-modal-description">
Retirer <strong>{getTeacherName(assignmentToDelete.teacherId)}</strong>
de <strong>{getSubjectName(assignmentToDelete.subjectId)}</strong>
en <strong>{getClassName(assignmentToDelete.classId)}</strong> ?
</p>
<p class="delete-warning">
Les notes existantes seront conservées, mais l'enseignant ne pourra plus en ajouter.
</p>
</div>
<div class="modal-actions modal-actions-padded">
<button type="button" class="btn-secondary" onclick={closeDeleteModal} disabled={isDeleting}>
Annuler
</button>
<button type="button" class="btn-danger" onclick={handleDelete} disabled={isDeleting}>
{#if isDeleting}
Retrait...
{:else}
Retirer
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.assignments-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-content h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
/* Buttons */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #dc2626;
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-remove {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-remove:hover {
background: #fee2e2;
}
.btn-icon {
font-size: 1.25rem;
line-height: 1;
}
/* Alerts */
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
}
.alert-icon {
flex-shrink: 0;
font-weight: bold;
}
.alert-close {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
}
.alert-close:hover {
opacity: 1;
}
/* Loading & Empty */
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
background: white;
border-radius: 0.75rem;
border: 2px dashed #e5e7eb;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #1f2937;
}
.empty-state p {
margin: 0 0 1.5rem;
color: #6b7280;
}
/* Table */
.table-container {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
overflow-x: auto;
}
.assignments-table {
width: 100%;
border-collapse: collapse;
}
.assignments-table th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.assignments-table td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #374151;
border-bottom: 1px solid #f3f4f6;
}
.assignments-table tr:last-child td {
border-bottom: none;
}
.assignments-table tr:hover td {
background: #f9fafb;
}
.teacher-cell {
white-space: nowrap;
}
.teacher-name {
font-weight: 500;
color: #1f2937;
}
.subject-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-active {
background: #f0fdf4;
color: #16a34a;
}
.date-cell {
white-space: nowrap;
color: #6b7280;
}
.actions-cell {
white-space: nowrap;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
}
.modal {
background: white;
border-radius: 0.75rem;
width: 100%;
max-width: 28rem;
max-height: 90vh;
overflow: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.modal-close {
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #6b7280;
cursor: pointer;
}
.modal-close:hover {
color: #1f2937;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
background: white;
}
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
padding: 0.75rem 1rem;
background: #eff6ff;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #3b82f6;
margin-bottom: 1.25rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.modal-actions-padded {
padding: 1rem 1.5rem;
}
/* Delete confirmation modal */
.modal-confirm {
max-width: 24rem;
}
.modal-header-danger {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.modal-header-danger h2 {
color: #dc2626;
}
.delete-warning {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
@media (min-width: 768px) {
.assignments-table th:nth-child(4),
.assignments-table td:nth-child(4),
.assignments-table th:nth-child(5),
.assignments-table td:nth-child(5) {
display: table-cell;
}
}
</style>

View File

@@ -17,6 +17,28 @@
updatedAt: string; updatedAt: string;
} }
interface TeacherAssignment {
id: string;
teacherId: string;
classId: string;
subjectId: string;
status: string;
startDate: string;
}
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
}
interface User {
id: string;
firstName: string;
lastName: string;
}
// State // State
let schoolClass = $state<SchoolClass | null>(null); let schoolClass = $state<SchoolClass | null>(null);
let isLoading = $state(true); let isLoading = $state(true);
@@ -24,6 +46,13 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let successMessage = $state<string | null>(null); let successMessage = $state<string | null>(null);
// Teacher assignments state
let classTeachers = $state<TeacherAssignment[]>([]);
let allSubjects = $state<Subject[]>([]);
let allTeachers = $state<User[]>([]);
let isLoadingTeachers = $state(false);
let teachersError = $state<string | null>(null);
// Form state (bound to schoolClass) // Form state (bound to schoolClass)
let formName = $state(''); let formName = $state('');
let formLevel = $state<string | null>(null); let formLevel = $state<string | null>(null);
@@ -37,10 +66,11 @@
const classId = $derived(page.params.id); const classId = $derived(page.params.id);
// Load class on mount // Load class and teachers on mount
$effect(() => { $effect(() => {
if (classId) { if (classId) {
loadClass(classId); loadClass(classId);
loadClassTeachers(classId);
} }
}); });
@@ -127,7 +157,7 @@
originalDescription = updatedClass.description; originalDescription = updatedClass.description;
// Clear success message after 3 seconds // Clear success message after 3 seconds
window.setTimeout(() => { globalThis.setTimeout(() => {
successMessage = null; successMessage = null;
}, 3000); }, 3000);
} catch (e) { } catch (e) {
@@ -137,6 +167,54 @@
} }
} }
async function loadClassTeachers(id: string) {
try {
isLoadingTeachers = true;
teachersError = null;
const apiUrl = getApiBaseUrl();
const [teachersRes, subjectsRes, usersRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/classes/${id}/teachers`),
authenticatedFetch(`${apiUrl}/subjects`),
authenticatedFetch(`${apiUrl}/users?role=ROLE_PROF`)
]);
if (!teachersRes.ok) throw new Error('Erreur lors du chargement des enseignants');
const teachersData = await teachersRes.json();
classTeachers = (teachersData['hydra:member'] ?? teachersData['member'] ?? (Array.isArray(teachersData) ? teachersData : []))
.filter((a: TeacherAssignment) => a.status === 'active');
if (subjectsRes.ok) {
const subjectsData = await subjectsRes.json();
allSubjects = subjectsData['hydra:member'] ?? subjectsData['member'] ?? (Array.isArray(subjectsData) ? subjectsData : []);
}
if (usersRes.ok) {
const usersData = await usersRes.json();
allTeachers = usersData['hydra:member'] ?? usersData['member'] ?? (Array.isArray(usersData) ? usersData : []);
}
} catch (e) {
teachersError = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoadingTeachers = false;
}
}
function getTeacherName(teacherId: string): string {
const teacher = allTeachers.find((t) => t.id === teacherId);
return teacher ? `${teacher.firstName} ${teacher.lastName}` : teacherId;
}
function getSubjectName(subjectId: string): string {
const subject = allSubjects.find((s) => s.id === subjectId);
return subject ? subject.name : subjectId;
}
function getSubjectColor(subjectId: string): string | null {
const subject = allSubjects.find((s) => s.id === subjectId);
return subject?.color ?? null;
}
function goBack() { function goBack() {
goto('/admin/classes'); goto('/admin/classes');
} }
@@ -251,13 +329,53 @@
</div> </div>
</form> </form>
</div> </div>
<!-- Teachers Section (AC5) -->
<section class="teachers-section">
<div class="section-header">
<h2>Enseignants affectés</h2>
<a href="/admin/assignments" class="btn-link">Gérer les affectations</a>
</div>
{#if teachersError}
<div class="alert alert-error">
{teachersError}
</div>
{/if}
{#if isLoadingTeachers}
<p class="section-loading">Chargement des enseignants...</p>
{:else if classTeachers.length === 0}
<p class="section-empty">Aucun enseignant affecté à cette classe.</p>
{:else}
<ul class="teacher-list">
{#each classTeachers as assignment (assignment.id)}
<li class="teacher-item">
<span class="teacher-name">{getTeacherName(assignment.teacherId)}</span>
{#if getSubjectColor(assignment.subjectId)}
<span
class="subject-tag"
style="background-color: {getSubjectColor(assignment.subjectId)}; color: white"
>
{getSubjectName(assignment.subjectId)}
</span>
{:else}
<span class="subject-tag subject-tag-default">
{getSubjectName(assignment.subjectId)}
</span>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/if} {/if}
</div> </div>
<style> <style>
.edit-page { .edit-page {
padding: 1.5rem; padding: 1.5rem;
max-width: 600px; max-width: 700px;
margin: 0 auto; margin: 0 auto;
} }
@@ -484,4 +602,81 @@
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: #f3f4f6; background: #f3f4f6;
} }
/* Teachers Section */
.teachers-section {
margin-top: 1.5rem;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.btn-link {
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
}
.btn-link:hover {
text-decoration: underline;
}
.section-loading,
.section-empty {
color: #6b7280;
font-size: 0.875rem;
text-align: center;
padding: 1rem;
}
.teacher-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.teacher-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.teacher-name {
font-weight: 500;
color: #1f2937;
}
.subject-tag {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.subject-tag-default {
background: #f3f4f6;
color: #374151;
}
</style> </style>

View File

@@ -124,7 +124,7 @@
successMessage = 'Matière mise à jour avec succès'; successMessage = 'Matière mise à jour avec succès';
// Clear success message after 3 seconds // Clear success message after 3 seconds
window.setTimeout(() => { globalThis.setTimeout(() => {
successMessage = null; successMessage = null;
}, 3000); }, 3000);
} catch (e) { } catch (e) {

View File

@@ -0,0 +1,388 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
statut: string;
}
interface TeacherAssignment {
id: string;
teacherId: string;
classId: string;
subjectId: string;
status: string;
startDate: string;
}
interface SchoolClass {
id: string;
name: string;
level: string | null;
}
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
}
let teacherId = $derived($page.params.id);
let teacher = $state<User | null>(null);
let assignments = $state<TeacherAssignment[]>([]);
let classes = $state<SchoolClass[]>([]);
let subjects = $state<Subject[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
function extractCollection<T>(data: Record<string, unknown>): T[] {
const hydra = data['hydra:member'];
if (Array.isArray(hydra)) return hydra as T[];
const member = data['member'];
if (Array.isArray(member)) return member as T[];
if (Array.isArray(data)) return data as T[];
return [];
}
function getClassName(classId: string): string {
const cls = classes.find((c) => c.id === classId);
return cls ? cls.name : classId;
}
function getClassLevel(classId: string): string | null {
const cls = classes.find((c) => c.id === classId);
return cls?.level ?? null;
}
function getSubjectName(subjectId: string): string {
const subject = subjects.find((s) => s.id === subjectId);
return subject ? subject.name : subjectId;
}
function getSubjectColor(subjectId: string): string | null {
const subject = subjects.find((s) => s.id === subjectId);
return subject?.color ?? null;
}
const activeAssignments = $derived(assignments.filter((a) => a.status === 'active'));
onMount(async () => {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const [teacherRes, assignmentsRes, classesRes, subjectsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/users/${teacherId}`),
authenticatedFetch(`${apiUrl}/teachers/${teacherId}/assignments`),
authenticatedFetch(`${apiUrl}/classes`),
authenticatedFetch(`${apiUrl}/subjects`)
]);
if (!teacherRes.ok) throw new Error('Enseignant non trouvé');
teacher = await teacherRes.json();
if (assignmentsRes.ok) {
const data = await assignmentsRes.json();
assignments = extractCollection(data);
}
if (classesRes.ok) {
const data = await classesRes.json();
classes = extractCollection(data);
}
if (subjectsRes.ok) {
const data = await subjectsRes.json();
subjects = extractCollection(data);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
});
</script>
<svelte:head>
<title>{teacher ? `${teacher.firstName} ${teacher.lastName}` : 'Enseignant'} - Classeo</title>
</svelte:head>
<div class="teacher-page">
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement du profil enseignant...</p>
</div>
{:else if error}
<div class="alert alert-error">{error}</div>
<a href="/admin/users" class="btn-back">Retour aux utilisateurs</a>
{:else if teacher}
<header class="page-header">
<div>
<a href="/admin/users" class="btn-back">Retour</a>
<h1>{teacher.firstName} {teacher.lastName}</h1>
<p class="subtitle">{teacher.email}</p>
</div>
</header>
<section class="info-card">
<h2>Informations</h2>
<dl class="info-grid">
<dt>Email</dt>
<dd>{teacher.email}</dd>
<dt>Statut</dt>
<dd><span class="status-badge status-{teacher.statut}">{teacher.statut}</span></dd>
<dt>Roles</dt>
<dd>{teacher.roles.join(', ')}</dd>
</dl>
</section>
<section class="assignments-card">
<div class="section-header">
<h2>Affectations ({activeAssignments.length})</h2>
<a href="/admin/assignments" class="btn-link">Gerer les affectations</a>
</div>
{#if activeAssignments.length === 0}
<p class="empty-state">Aucune affectation pour cet enseignant.</p>
{:else}
<ul class="assignment-list">
{#each activeAssignments as assignment (assignment.id)}
<li class="assignment-item">
<div class="assignment-info">
<span class="class-name">
{getClassName(assignment.classId)}
{#if getClassLevel(assignment.classId)}
<span class="class-level">({getClassLevel(assignment.classId)})</span>
{/if}
</span>
{#if getSubjectColor(assignment.subjectId)}
<span
class="subject-badge"
style="background-color: {getSubjectColor(assignment.subjectId)}; color: white"
>
{getSubjectName(assignment.subjectId)}
</span>
{:else}
<span class="subject-badge">{getSubjectName(assignment.subjectId)}</span>
{/if}
</div>
<span class="assignment-date">
Depuis le {new Date(assignment.startDate).toLocaleDateString('fr-FR')}
</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</div>
<style>
.teacher-page {
padding: 1.5rem;
max-width: 700px;
margin: 0 auto;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem;
text-align: center;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.alert-error {
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.btn-back {
display: inline-block;
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
margin-bottom: 0.5rem;
}
.btn-back:hover {
text-decoration: underline;
}
.page-header {
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
.info-card,
.assignments-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.info-card h2,
.assignments-card h2 {
margin: 0 0 1rem;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.info-grid {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.5rem 1rem;
margin: 0;
}
.info-grid dt {
font-weight: 500;
color: #6b7280;
font-size: 0.875rem;
}
.info-grid dd {
margin: 0;
font-size: 0.875rem;
color: #1f2937;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
background: #f3f4f6;
color: #374151;
}
.status-active {
background: #f0fdf4;
color: #16a34a;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 {
margin: 0;
}
.btn-link {
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
}
.btn-link:hover {
text-decoration: underline;
}
.empty-state {
color: #6b7280;
font-size: 0.875rem;
text-align: center;
padding: 1rem;
}
.assignment-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.assignment-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
gap: 1rem;
}
.assignment-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.class-name {
font-weight: 500;
color: #1f2937;
}
.class-level {
font-weight: 400;
color: #6b7280;
font-size: 0.875rem;
}
.subject-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
background: #e5e7eb;
color: #374151;
}
.assignment-date {
font-size: 0.75rem;
color: #9ca3af;
white-space: nowrap;
}
</style>

View File

@@ -48,6 +48,10 @@ describe('auth service', () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.stubGlobal('fetch', vi.fn()); vi.stubGlobal('fetch', vi.fn());
// Silence expected console.error/warn from error-handling tests
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
// Re-mock goto for each test // Re-mock goto for each test
const navModule = await import('$app/navigation'); const navModule = await import('$app/navigation');
(navModule.goto as ReturnType<typeof vi.fn>).mockImplementation(mockGoto); (navModule.goto as ReturnType<typeof vi.fn>).mockImplementation(mockGoto);