feat: Gestion des classes scolaires

Permet aux administrateurs de créer, modifier et supprimer des classes
pour organiser les élèves par niveau. L'archivage soft-delete préserve
l'historique tout en masquant les classes obsolètes.

Inclut la validation des noms (2-50 caractères), les niveaux scolaires
du CP à la Terminale, et les contrôles d'accès par rôle.
This commit is contained in:
2026-02-05 15:24:29 +01:00
parent b45ef735db
commit 8e09e0abf1
54 changed files with 5099 additions and 5 deletions

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ArchiveClass;
use App\Administration\Application\Command\ArchiveClass\ArchiveClassCommand;
use App\Administration\Application\Command\ArchiveClass\ArchiveClassHandler;
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassQuery;
use App\Administration\Domain\Exception\ClasseNonSupprimableException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
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\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
final class ArchiveClassHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
private InMemoryClassRepository $classRepository;
private Clock $clock;
protected function setUp(): void
{
$this->classRepository = new InMemoryClassRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-01 10:00:00');
}
};
}
#[Test]
public function itArchivesEmptyClass(): void
{
$class = $this->createAndSaveClass();
$queryBus = $this->createQueryBusReturning(0);
$handler = new ArchiveClassHandler($this->classRepository, $queryBus, $this->clock);
$command = new ArchiveClassCommand(classId: (string) $class->id);
$handler($command);
$archivedClass = $this->classRepository->get($class->id);
self::assertSame(ClassStatus::ARCHIVED, $archivedClass->status);
self::assertNotNull($archivedClass->deletedAt);
}
#[Test]
public function itThrowsExceptionWhenStudentsAreAffected(): void
{
$class = $this->createAndSaveClass();
$queryBus = $this->createQueryBusReturning(5);
$handler = new ArchiveClassHandler($this->classRepository, $queryBus, $this->clock);
$command = new ArchiveClassCommand(classId: (string) $class->id);
$this->expectException(ClasseNonSupprimableException::class);
$this->expectExceptionMessage('5 élève(s)');
$handler($command);
}
#[Test]
public function itDoesNotModifyStatusWhenAlreadyArchived(): void
{
$class = $this->createAndSaveClass();
$archiveTime = new DateTimeImmutable('2026-01-20 10:00:00');
$class->archiver($archiveTime);
$this->classRepository->save($class);
$queryBus = $this->createQueryBusReturning(0);
$handler = new ArchiveClassHandler($this->classRepository, $queryBus, $this->clock);
$command = new ArchiveClassCommand(classId: (string) $class->id);
$handler($command);
$archivedClass = $this->classRepository->get($class->id);
// deletedAt should not have changed
self::assertEquals($archiveTime, $archivedClass->deletedAt);
}
private function createAndSaveClass(): SchoolClass
{
$class = SchoolClass::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
schoolId: SchoolId::fromString(self::SCHOOL_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
name: new ClassName('6ème A'),
level: SchoolLevel::SIXIEME,
capacity: 30,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
$this->classRepository->save($class);
return $class;
}
private function createQueryBusReturning(int $studentCount): MessageBusInterface
{
return new class($studentCount) implements MessageBusInterface {
public function __construct(private readonly int $studentCount)
{
}
public function dispatch(object $message, array $stamps = []): Envelope
{
if (!$message instanceof HasStudentsInClassQuery) {
throw new RuntimeException('Unexpected message type');
}
$envelope = new Envelope($message);
return $envelope->with(new HandledStamp($this->studentCount, 'handler'));
}
};
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\CreateClass;
use App\Administration\Application\Command\CreateClass\CreateClassCommand;
use App\Administration\Application\Command\CreateClass\CreateClassHandler;
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CreateClassHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
private InMemoryClassRepository $classRepository;
private Clock $clock;
protected function setUp(): void
{
$this->classRepository = new InMemoryClassRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-01-31 10:00:00');
}
};
}
#[Test]
public function itCreatesClassSuccessfully(): void
{
$handler = new CreateClassHandler($this->classRepository, $this->clock);
$command = new CreateClassCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
name: '6ème A',
level: SchoolLevel::SIXIEME->value,
capacity: 30,
);
$class = $handler($command);
self::assertNotEmpty((string) $class->id);
self::assertSame('6ème A', (string) $class->name);
self::assertSame(SchoolLevel::SIXIEME, $class->level);
self::assertSame(30, $class->capacity);
}
#[Test]
public function itCreatesClassWithNullLevelAndCapacity(): void
{
$handler = new CreateClassHandler($this->classRepository, $this->clock);
$command = new CreateClassCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
name: 'Classe spéciale',
level: null,
capacity: null,
);
$class = $handler($command);
self::assertNotEmpty((string) $class->id);
self::assertSame('Classe spéciale', (string) $class->name);
self::assertNull($class->level);
self::assertNull($class->capacity);
}
#[Test]
public function itPersistsClassInRepository(): void
{
$handler = new CreateClassHandler($this->classRepository, $this->clock);
$command = new CreateClassCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
name: '6ème A',
level: SchoolLevel::SIXIEME->value,
capacity: 30,
);
$createdClass = $handler($command);
$class = $this->classRepository->get(
ClassId::fromString((string) $createdClass->id),
);
self::assertSame('6ème A', (string) $class->name);
self::assertSame(ClassStatus::ACTIVE, $class->status);
self::assertSame(SchoolLevel::SIXIEME, $class->level);
self::assertSame(30, $class->capacity);
}
#[Test]
public function itThrowsExceptionWhenClassNameAlreadyExists(): void
{
$handler = new CreateClassHandler($this->classRepository, $this->clock);
$command = new CreateClassCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
name: '6ème A',
level: SchoolLevel::SIXIEME->value,
capacity: 30,
);
// First creation should succeed
$handler($command);
// Second creation with same name should throw
$this->expectException(ClasseDejaExistanteException::class);
$this->expectExceptionMessage('Une classe avec le nom "6ème A" existe déjà pour cette année scolaire.');
$handler($command);
}
#[Test]
public function itAllowsSameNameInDifferentTenant(): void
{
$handler = new CreateClassHandler($this->classRepository, $this->clock);
// Create in tenant 1
$command1 = new CreateClassCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
name: '6ème A',
level: SchoolLevel::SIXIEME->value,
capacity: 30,
);
$class1 = $handler($command1);
// Create same name in tenant 2 should succeed
$command2 = new CreateClassCommand(
tenantId: '550e8400-e29b-41d4-a716-446655440099',
schoolId: self::SCHOOL_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
name: '6ème A',
level: SchoolLevel::SIXIEME->value,
capacity: 30,
);
$class2 = $handler($command2);
self::assertFalse($class1->id->equals($class2->id));
self::assertSame('6ème A', (string) $class1->name);
self::assertSame('6ème A', (string) $class2->name);
}
#[Test]
public function itAllowsSameNameInDifferentAcademicYear(): void
{
$handler = new CreateClassHandler($this->classRepository, $this->clock);
// Create in year 1
$command1 = new CreateClassCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
name: '6ème A',
level: SchoolLevel::SIXIEME->value,
capacity: 30,
);
$class1 = $handler($command1);
// Create same name in year 2 should succeed
$command2 = new CreateClassCommand(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
academicYearId: '550e8400-e29b-41d4-a716-446655440099',
name: '6ème A',
level: SchoolLevel::SIXIEME->value,
capacity: 30,
);
$class2 = $handler($command2);
self::assertFalse($class1->id->equals($class2->id));
self::assertSame('6ème A', (string) $class1->name);
self::assertSame('6ème A', (string) $class2->name);
}
}

View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\UpdateClass;
use App\Administration\Application\Command\UpdateClass\UpdateClassCommand;
use App\Administration\Application\Command\UpdateClass\UpdateClassHandler;
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
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\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class UpdateClassHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
private InMemoryClassRepository $classRepository;
private Clock $clock;
protected function setUp(): void
{
$this->classRepository = new InMemoryClassRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-01 10:00:00');
}
};
}
#[Test]
public function itUpdatesClassName(): void
{
$class = $this->createAndSaveClass();
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
$command = new UpdateClassCommand(
classId: (string) $class->id,
name: '6ème B',
);
$handler($command);
$updatedClass = $this->classRepository->get($class->id);
self::assertSame('6ème B', (string) $updatedClass->name);
}
#[Test]
public function itUpdatesClassLevel(): void
{
$class = $this->createAndSaveClass();
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
$command = new UpdateClassCommand(
classId: (string) $class->id,
level: SchoolLevel::CINQUIEME->value,
);
$handler($command);
$updatedClass = $this->classRepository->get($class->id);
self::assertSame(SchoolLevel::CINQUIEME, $updatedClass->level);
}
#[Test]
public function itClearsClassLevel(): void
{
$class = $this->createAndSaveClass();
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
$command = new UpdateClassCommand(
classId: (string) $class->id,
clearLevel: true,
);
$handler($command);
$updatedClass = $this->classRepository->get($class->id);
self::assertNull($updatedClass->level);
}
#[Test]
public function itUpdatesClassCapacity(): void
{
$class = $this->createAndSaveClass();
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
$command = new UpdateClassCommand(
classId: (string) $class->id,
capacity: 35,
);
$handler($command);
$updatedClass = $this->classRepository->get($class->id);
self::assertSame(35, $updatedClass->capacity);
}
#[Test]
public function itUpdatesClassDescription(): void
{
$class = $this->createAndSaveClass();
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
$command = new UpdateClassCommand(
classId: (string) $class->id,
description: 'Classe option musique',
);
$handler($command);
$updatedClass = $this->classRepository->get($class->id);
self::assertSame('Classe option musique', $updatedClass->description);
}
#[Test]
public function itUpdatesMultipleFields(): void
{
$class = $this->createAndSaveClass();
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
$command = new UpdateClassCommand(
classId: (string) $class->id,
name: '5ème C',
level: SchoolLevel::CINQUIEME->value,
capacity: 28,
description: 'Section européenne',
);
$handler($command);
$updatedClass = $this->classRepository->get($class->id);
self::assertSame('5ème C', (string) $updatedClass->name);
self::assertSame(SchoolLevel::CINQUIEME, $updatedClass->level);
self::assertSame(28, $updatedClass->capacity);
self::assertSame('Section européenne', $updatedClass->description);
}
#[Test]
public function itThrowsExceptionWhenRenamingToExistingName(): void
{
// Create first class
$class1 = $this->createAndSaveClass();
// Create second class with different name
$class2 = SchoolClass::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
schoolId: SchoolId::fromString(self::SCHOOL_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
name: new ClassName('6ème B'),
level: SchoolLevel::SIXIEME,
capacity: 30,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
$this->classRepository->save($class2);
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
// Try to rename class2 to class1's name
$command = new UpdateClassCommand(
classId: (string) $class2->id,
name: '6ème A',
);
$this->expectException(ClasseDejaExistanteException::class);
$this->expectExceptionMessage('Une classe avec le nom "6ème A" existe déjà pour cette année scolaire.');
$handler($command);
}
#[Test]
public function itAllowsRenamingToSameName(): void
{
$class = $this->createAndSaveClass();
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
// Renaming to the same name should work
$command = new UpdateClassCommand(
classId: (string) $class->id,
name: '6ème A',
);
$handler($command);
$updatedClass = $this->classRepository->get($class->id);
self::assertSame('6ème A', (string) $updatedClass->name);
}
private function createAndSaveClass(): SchoolClass
{
$class = SchoolClass::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
schoolId: SchoolId::fromString(self::SCHOOL_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
name: new ClassName('6ème A'),
level: SchoolLevel::SIXIEME,
capacity: 30,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
$this->classRepository->save($class);
return $class;
}
}