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,104 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolClass;
use App\Administration\Domain\Exception\ClassNameInvalideException;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ClassNameTest extends TestCase
{
#[Test]
public function constructWithValidName(): void
{
$name = new ClassName('6ème A');
self::assertSame('6ème A', $name->value);
self::assertSame('6ème A', (string) $name);
}
#[Test]
public function constructTrimsWhitespace(): void
{
$name = new ClassName(' 6ème A ');
self::assertSame('6ème A', $name->value);
}
#[Test]
#[DataProvider('validNamesProvider')]
public function constructAcceptsValidNames(string $value): void
{
$name = new ClassName($value);
self::assertNotEmpty($name->value);
}
/**
* @return array<string, array{string}>
*/
public static function validNamesProvider(): array
{
return [
'minimum length' => ['AB'],
'typical class name' => ['6ème A'],
'longer name' => ['Classe préparatoire aux grandes écoles'],
'with numbers' => ['CM1-2'],
'maximum length' => [str_repeat('A', 50)],
];
}
#[Test]
#[DataProvider('invalidNamesProvider')]
public function constructRejectsInvalidNames(string $value): void
{
$this->expectException(ClassNameInvalideException::class);
new ClassName($value);
}
/**
* @return array<string, array{string}>
*/
public static function invalidNamesProvider(): array
{
return [
'empty string' => [''],
'single character' => ['A'],
'only whitespace' => [' '],
'one char after trim' => [' A '],
'too long' => [str_repeat('A', 51)],
];
}
#[Test]
public function equalsReturnsTrueForSameValue(): void
{
$name1 = new ClassName('6ème A');
$name2 = new ClassName('6ème A');
self::assertTrue($name1->equals($name2));
}
#[Test]
public function equalsReturnsFalseForDifferentValue(): void
{
$name1 = new ClassName('6ème A');
$name2 = new ClassName('6ème B');
self::assertFalse($name1->equals($name2));
}
#[Test]
public function equalsIsCaseSensitive(): void
{
$name1 = new ClassName('Classe A');
$name2 = new ClassName('classe A');
self::assertFalse($name1->equals($name2));
}
}

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolClass;
use App\Administration\Domain\Event\ClasseArchivee;
use App\Administration\Domain\Event\ClasseCreee;
use App\Administration\Domain\Event\ClasseModifiee;
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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SchoolClassTest 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';
#[Test]
public function creerCreatesClassWithActiveStatus(): void
{
$class = $this->createClass();
self::assertSame(ClassStatus::ACTIVE, $class->status);
self::assertTrue($class->estActive());
self::assertTrue($class->peutRecevoirEleves());
}
#[Test]
public function creerRecordsClasseCreeeEvent(): void
{
$class = $this->createClass();
$events = $class->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ClasseCreee::class, $events[0]);
self::assertSame($class->id, $events[0]->classId);
self::assertSame($class->tenantId, $events[0]->tenantId);
self::assertSame($class->name, $events[0]->name);
}
#[Test]
public function creerSetsAllProperties(): void
{
$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');
$class = SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: $name,
level: $level,
capacity: $capacity,
createdAt: $createdAt,
);
self::assertTrue($class->tenantId->equals($tenantId));
self::assertTrue($class->schoolId->equals($schoolId));
self::assertTrue($class->academicYearId->equals($academicYearId));
self::assertTrue($class->name->equals($name));
self::assertSame($level, $class->level);
self::assertSame($capacity, $class->capacity);
self::assertEquals($createdAt, $class->createdAt);
self::assertEquals($createdAt, $class->updatedAt);
self::assertNull($class->deletedAt);
self::assertNull($class->description);
}
#[Test]
public function creerWithNullLevelAndCapacity(): void
{
$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('Classe spéciale'),
level: null,
capacity: null,
createdAt: new DateTimeImmutable(),
);
self::assertNull($class->level);
self::assertNull($class->capacity);
}
#[Test]
public function renommerChangesNameAndRecordsEvent(): void
{
$class = $this->createClass();
$class->pullDomainEvents();
$ancienNom = $class->name;
$nouveauNom = new ClassName('6ème B');
$at = new DateTimeImmutable('2026-02-01 10:00:00');
$class->renommer($nouveauNom, $at);
self::assertTrue($class->name->equals($nouveauNom));
self::assertEquals($at, $class->updatedAt);
$events = $class->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ClasseModifiee::class, $events[0]);
self::assertTrue($events[0]->ancienNom->equals($ancienNom));
self::assertTrue($events[0]->nouveauNom->equals($nouveauNom));
}
#[Test]
public function renommerWithSameNameDoesNothing(): void
{
$class = $this->createClass();
$class->pullDomainEvents();
$originalUpdatedAt = $class->updatedAt;
$class->renommer(new ClassName('6ème A'), new DateTimeImmutable('2026-02-01 10:00:00'));
self::assertEquals($originalUpdatedAt, $class->updatedAt);
self::assertEmpty($class->pullDomainEvents());
}
#[Test]
public function changerNiveauUpdatesLevel(): void
{
$class = $this->createClass();
$at = new DateTimeImmutable('2026-02-01 10:00:00');
$class->changerNiveau(SchoolLevel::CINQUIEME, $at);
self::assertSame(SchoolLevel::CINQUIEME, $class->level);
self::assertEquals($at, $class->updatedAt);
}
#[Test]
public function changerNiveauWithSameLevelDoesNothing(): void
{
$class = $this->createClass();
$originalUpdatedAt = $class->updatedAt;
$class->changerNiveau(SchoolLevel::SIXIEME, new DateTimeImmutable('2026-02-01 10:00:00'));
self::assertEquals($originalUpdatedAt, $class->updatedAt);
}
#[Test]
public function changerCapaciteUpdatesCapacity(): void
{
$class = $this->createClass();
$at = new DateTimeImmutable('2026-02-01 10:00:00');
$class->changerCapacite(35, $at);
self::assertSame(35, $class->capacity);
self::assertEquals($at, $class->updatedAt);
}
#[Test]
public function changerCapaciteWithSameValueDoesNothing(): void
{
$class = $this->createClass();
$originalUpdatedAt = $class->updatedAt;
$class->changerCapacite(30, new DateTimeImmutable('2026-02-01 10:00:00'));
self::assertEquals($originalUpdatedAt, $class->updatedAt);
}
#[Test]
public function decrireUpdatesDescription(): void
{
$class = $this->createClass();
$at = new DateTimeImmutable('2026-02-01 10:00:00');
$class->decrire('Classe option musique', $at);
self::assertSame('Classe option musique', $class->description);
self::assertEquals($at, $class->updatedAt);
}
#[Test]
public function archiverChangesStatusAndRecordsEvent(): void
{
$class = $this->createClass();
$class->pullDomainEvents();
$at = new DateTimeImmutable('2026-02-01 10:00:00');
$class->archiver($at);
self::assertSame(ClassStatus::ARCHIVED, $class->status);
self::assertFalse($class->estActive());
self::assertFalse($class->peutRecevoirEleves());
self::assertEquals($at, $class->deletedAt);
self::assertEquals($at, $class->updatedAt);
$events = $class->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ClasseArchivee::class, $events[0]);
}
#[Test]
public function archiverAlreadyArchivedClassDoesNothing(): void
{
$class = $this->createClass();
$class->archiver(new DateTimeImmutable('2026-02-01 10:00:00'));
$class->pullDomainEvents();
$originalDeletedAt = $class->deletedAt;
$class->archiver(new DateTimeImmutable('2026-02-02 10:00:00'));
self::assertEquals($originalDeletedAt, $class->deletedAt);
self::assertEmpty($class->pullDomainEvents());
}
#[Test]
public function reconstituteRestoresAllProperties(): void
{
$id = \App\Administration\Domain\Model\SchoolClass\ClassId::generate();
$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;
$status = ClassStatus::ARCHIVED;
$description = 'Test description';
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$updatedAt = new DateTimeImmutable('2026-02-01 10:00:00');
$deletedAt = new DateTimeImmutable('2026-02-01 10:00:00');
$class = SchoolClass::reconstitute(
id: $id,
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: $name,
level: $level,
capacity: $capacity,
status: $status,
description: $description,
createdAt: $createdAt,
updatedAt: $updatedAt,
deletedAt: $deletedAt,
);
self::assertTrue($class->id->equals($id));
self::assertTrue($class->tenantId->equals($tenantId));
self::assertTrue($class->schoolId->equals($schoolId));
self::assertTrue($class->academicYearId->equals($academicYearId));
self::assertTrue($class->name->equals($name));
self::assertSame($level, $class->level);
self::assertSame($capacity, $class->capacity);
self::assertSame($status, $class->status);
self::assertSame($description, $class->description);
self::assertEquals($createdAt, $class->createdAt);
self::assertEquals($updatedAt, $class->updatedAt);
self::assertEquals($deletedAt, $class->deletedAt);
self::assertEmpty($class->pullDomainEvents());
}
private function createClass(): SchoolClass
{
return 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'),
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SchoolLevelTest extends TestCase
{
#[Test]
#[DataProvider('primaryLevelsProvider')]
public function estPrimaireReturnsTrueForPrimaryLevels(SchoolLevel $level): void
{
self::assertTrue($level->estPrimaire());
self::assertFalse($level->estCollege());
self::assertFalse($level->estLycee());
self::assertSame('Primaire', $level->cycle());
}
/**
* @return array<string, array{SchoolLevel}>
*/
public static function primaryLevelsProvider(): array
{
return [
'CP' => [SchoolLevel::CP],
'CE1' => [SchoolLevel::CE1],
'CE2' => [SchoolLevel::CE2],
'CM1' => [SchoolLevel::CM1],
'CM2' => [SchoolLevel::CM2],
];
}
#[Test]
#[DataProvider('collegeLevelsProvider')]
public function estCollegeReturnsTrueForCollegeLevels(SchoolLevel $level): void
{
self::assertFalse($level->estPrimaire());
self::assertTrue($level->estCollege());
self::assertFalse($level->estLycee());
self::assertSame('Collège', $level->cycle());
}
/**
* @return array<string, array{SchoolLevel}>
*/
public static function collegeLevelsProvider(): array
{
return [
'6ème' => [SchoolLevel::SIXIEME],
'5ème' => [SchoolLevel::CINQUIEME],
'4ème' => [SchoolLevel::QUATRIEME],
'3ème' => [SchoolLevel::TROISIEME],
];
}
#[Test]
#[DataProvider('lyceeLevelsProvider')]
public function estLyceeReturnsTrueForLyceeLevels(SchoolLevel $level): void
{
self::assertFalse($level->estPrimaire());
self::assertFalse($level->estCollege());
self::assertTrue($level->estLycee());
self::assertSame('Lycée', $level->cycle());
}
/**
* @return array<string, array{SchoolLevel}>
*/
public static function lyceeLevelsProvider(): array
{
return [
'2nde' => [SchoolLevel::SECONDE],
'1ère' => [SchoolLevel::PREMIERE],
'Terminale' => [SchoolLevel::TERMINALE],
];
}
#[Test]
public function labelReturnsValue(): void
{
self::assertSame('6ème', SchoolLevel::SIXIEME->label());
self::assertSame('CM2', SchoolLevel::CM2->label());
self::assertSame('Terminale', SchoolLevel::TERMINALE->label());
}
#[Test]
public function valueMatchesExpectedString(): void
{
self::assertSame('CP', SchoolLevel::CP->value);
self::assertSame('6ème', SchoolLevel::SIXIEME->value);
self::assertSame('2nde', SchoolLevel::SECONDE->value);
}
}