feat: Permettre la création et modification de l'emploi du temps des classes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

L'administration a besoin de construire et maintenir les emplois du temps
hebdomadaires pour chaque classe, en s'assurant que les enseignants ne sont
pas en conflit (même créneau, classes différentes) et que les affectations
enseignant-matière-classe sont respectées.

Cette implémentation couvre le CRUD complet des créneaux (ScheduleSlot),
la détection de conflits (classe, enseignant, salle) avec possibilité de
forcer, la validation des affectations côté serveur (AC2), l'intégration
calendrier pour les jours bloqués, une vue mobile-first avec onglets jour
par jour, et le drag-and-drop pour réorganiser les créneaux sur desktop.
This commit is contained in:
2026-03-03 13:54:53 +01:00
parent 1db8a7a0b2
commit d103b34023
53 changed files with 6382 additions and 1 deletions

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\CreateScheduleSlot;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\CreateScheduleSlot\CreateScheduleSlotCommand;
use App\Scolarite\Application\Command\CreateScheduleSlot\CreateScheduleSlotHandler;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CreateScheduleSlotHandlerTest 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 SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $repository;
private CreateScheduleSlotHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-02 10:00:00');
}
};
$this->handler = new CreateScheduleSlotHandler(
$this->repository,
new ScheduleConflictDetector($this->repository),
$this->createAlwaysAssignedChecker(),
$clock,
);
}
#[Test]
public function createsSlotSuccessfully(): void
{
$result = ($this->handler)($this->createCommand());
/** @var ScheduleSlot $slot */
$slot = $result['slot'];
self::assertTrue($slot->classId->equals(ClassId::fromString(self::CLASS_ID)));
self::assertSame(DayOfWeek::MONDAY, $slot->dayOfWeek);
self::assertSame('08:00', $slot->timeSlot->startTime);
self::assertSame('09:00', $slot->timeSlot->endTime);
self::assertEmpty($result['conflicts']);
}
#[Test]
public function savesSlotToRepository(): void
{
$result = ($this->handler)($this->createCommand());
/** @var ScheduleSlot $slot */
$slot = $result['slot'];
$found = $this->repository->findById($slot->id, TenantId::fromString(self::TENANT_ID));
self::assertNotNull($found);
self::assertTrue($found->id->equals($slot->id));
}
#[Test]
public function detectsConflictsWithoutSaving(): void
{
// Créer un slot existant
($this->handler)($this->createCommand());
// Même enseignant, même créneau, classe différente
$result = ($this->handler)(new CreateScheduleSlotCommand(
tenantId: self::TENANT_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:30',
endTime: '09:30',
room: null,
));
self::assertNotEmpty($result['conflicts']);
// Le slot conflictuel ne devrait PAS être sauvegardé
$allSlots = $this->repository->findByTeacher(
UserId::fromString(self::TEACHER_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertCount(1, $allSlots);
}
#[Test]
public function forceSavesSlotWithConflicts(): void
{
($this->handler)($this->createCommand());
$result = ($this->handler)(new CreateScheduleSlotCommand(
tenantId: self::TENANT_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:30',
endTime: '09:30',
room: null,
forceConflicts: true,
));
self::assertNotEmpty($result['conflicts']);
// Malgré les conflits, le slot est sauvegardé
$allSlots = $this->repository->findByTeacher(
UserId::fromString(self::TEACHER_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertCount(2, $allSlots);
}
#[Test]
public function rejectsUnassignedTeacher(): void
{
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-02 10:00:00');
}
};
$handler = new CreateScheduleSlotHandler(
$this->repository,
new ScheduleConflictDetector($this->repository),
$this->createNeverAssignedChecker(),
$clock,
);
$this->expectException(EnseignantNonAffecteException::class);
($handler)($this->createCommand());
}
private function createCommand(): CreateScheduleSlotCommand
{
return new CreateScheduleSlotCommand(
tenantId: self::TENANT_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: null,
);
}
private function createAlwaysAssignedChecker(): EnseignantAffectationChecker
{
return new class implements EnseignantAffectationChecker {
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return true;
}
};
}
private function createNeverAssignedChecker(): EnseignantAffectationChecker
{
return new class implements EnseignantAffectationChecker {
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return false;
}
};
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\DeleteScheduleSlot;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\DeleteScheduleSlot\DeleteScheduleSlotCommand;
use App\Scolarite\Application\Command\DeleteScheduleSlot\DeleteScheduleSlotHandler;
use App\Scolarite\Domain\Event\CoursSupprime;
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DeleteScheduleSlotHandlerTest 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 SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $repository;
private DeleteScheduleSlotHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-02 15:00:00');
}
};
$this->handler = new DeleteScheduleSlotHandler($this->repository, $clock);
}
#[Test]
public function deletesSlotFromRepository(): void
{
$slot = $this->createAndSaveSlot();
$tenantId = TenantId::fromString(self::TENANT_ID);
($this->handler)(new DeleteScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
));
self::assertNull($this->repository->findById($slot->id, $tenantId));
}
#[Test]
public function returnsSlotWithCoursSupprime(): void
{
$slot = $this->createAndSaveSlot();
$slot->pullDomainEvents(); // Clear creation event
$deletedSlot = ($this->handler)(new DeleteScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
));
$events = $deletedSlot->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CoursSupprime::class, $events[0]);
}
#[Test]
public function throwsWhenSlotNotFound(): void
{
$this->expectException(ScheduleSlotNotFoundException::class);
($this->handler)(new DeleteScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: '550e8400-e29b-41d4-a716-446655440099',
));
}
private function createAndSaveSlot(): ScheduleSlot
{
$slot = ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
dayOfWeek: DayOfWeek::MONDAY,
timeSlot: new TimeSlot('08:00', '09:00'),
room: null,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
$this->repository->save($slot);
return $slot;
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\UpdateScheduleSlot;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\UpdateScheduleSlot\UpdateScheduleSlotCommand;
use App\Scolarite\Application\Command\UpdateScheduleSlot\UpdateScheduleSlotHandler;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class UpdateScheduleSlotHandlerTest 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 OTHER_CLASS_ID = '550e8400-e29b-41d4-a716-446655440021';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $repository;
private UpdateScheduleSlotHandler $handler;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-02 15:00:00');
}
};
$this->handler = new UpdateScheduleSlotHandler(
$this->repository,
new ScheduleConflictDetector($this->repository),
$this->createAlwaysAssignedChecker(),
$this->clock,
);
}
#[Test]
public function updatesSlotProperties(): void
{
$slot = $this->createAndSaveSlot();
$newSubjectId = '550e8400-e29b-41d4-a716-446655440031';
$result = ($this->handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::CLASS_ID,
subjectId: $newSubjectId,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::WEDNESDAY->value,
startTime: '10:00',
endTime: '11:00',
room: 'Salle 301',
));
/** @var ScheduleSlot $updated */
$updated = $result['slot'];
self::assertTrue($updated->subjectId->equals(SubjectId::fromString($newSubjectId)));
self::assertSame(DayOfWeek::WEDNESDAY, $updated->dayOfWeek);
self::assertSame('10:00', $updated->timeSlot->startTime);
self::assertSame('Salle 301', $updated->room);
self::assertEmpty($result['conflicts']);
}
#[Test]
public function updatesClassId(): void
{
$slot = $this->createAndSaveSlot();
$result = ($this->handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::OTHER_CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: null,
));
/** @var ScheduleSlot $updated */
$updated = $result['slot'];
self::assertTrue($updated->classId->equals(ClassId::fromString(self::OTHER_CLASS_ID)));
self::assertFalse($updated->classId->equals(ClassId::fromString(self::CLASS_ID)));
}
#[Test]
public function persistsClassIdChange(): void
{
$slot = $this->createAndSaveSlot();
($this->handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::OTHER_CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: null,
));
$persisted = $this->repository->get($slot->id, TenantId::fromString(self::TENANT_ID));
self::assertTrue($persisted->classId->equals(ClassId::fromString(self::OTHER_CLASS_ID)));
}
#[Test]
public function excludesSelfFromConflictDetection(): void
{
$slot = $this->createAndSaveSlot();
// Modifier le slot sans changer le créneau → pas de conflit avec lui-même
$result = ($this->handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: 'Salle 101',
));
self::assertEmpty($result['conflicts']);
}
#[Test]
public function rejectsUnassignedTeacher(): void
{
$slot = $this->createAndSaveSlot();
$handler = new UpdateScheduleSlotHandler(
$this->repository,
new ScheduleConflictDetector($this->repository),
$this->createNeverAssignedChecker(),
$this->clock,
);
$this->expectException(EnseignantNonAffecteException::class);
($handler)(new UpdateScheduleSlotCommand(
tenantId: self::TENANT_ID,
slotId: (string) $slot->id,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY->value,
startTime: '08:00',
endTime: '09:00',
room: null,
));
}
private function createAndSaveSlot(): ScheduleSlot
{
$slot = ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
dayOfWeek: DayOfWeek::MONDAY,
timeSlot: new TimeSlot('08:00', '09:00'),
room: null,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
$this->repository->save($slot);
return $slot;
}
private function createAlwaysAssignedChecker(): EnseignantAffectationChecker
{
return new class implements EnseignantAffectationChecker {
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return true;
}
};
}
private function createNeverAssignedChecker(): EnseignantAffectationChecker
{
return new class implements EnseignantAffectationChecker {
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
TenantId $tenantId,
): bool {
return false;
}
};
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetBlockedDates;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler;
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetBlockedDatesHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440050';
private InMemorySchoolCalendarRepository $calendarRepository;
private GetBlockedDatesHandler $handler;
protected function setUp(): void
{
$this->calendarRepository = new InMemorySchoolCalendarRepository();
$this->handler = new GetBlockedDatesHandler($this->calendarRepository);
}
#[Test]
public function returnsWeekendsAsBlocked(): void
{
// 2026-03-02 est un lundi, 2026-03-08 est un dimanche
$result = ($this->handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-08',
));
$weekendDates = array_filter($result, static fn ($d) => $d->type === 'weekend');
self::assertCount(2, $weekendDates);
$dates = array_map(static fn ($d) => $d->date, array_values($weekendDates));
self::assertContains('2026-03-07', $dates);
self::assertContains('2026-03-08', $dates);
}
#[Test]
public function returnsHolidaysAsBlocked(): void
{
$calendar = $this->createCalendarWithHoliday(
new DateTimeImmutable('2026-03-04'),
'Jour de test',
);
$this->calendarRepository->save($calendar);
$result = ($this->handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-06',
));
$holidays = array_filter($result, static fn ($d) => $d->type === CalendarEntryType::HOLIDAY->value);
self::assertCount(1, $holidays);
$holiday = array_values($holidays)[0];
self::assertSame('2026-03-04', $holiday->date);
self::assertSame('Jour de test', $holiday->reason);
}
#[Test]
public function returnsEmptyForWeekWithNoBlockedDates(): void
{
// Lundi à vendredi sans calendrier configuré
$result = ($this->handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-06',
));
self::assertEmpty($result);
}
#[Test]
public function returnsVacationsAsBlocked(): void
{
$calendar = $this->createCalendarWithVacation(
new DateTimeImmutable('2026-03-02'),
new DateTimeImmutable('2026-03-06'),
'Vacances de printemps',
);
$this->calendarRepository->save($calendar);
$result = ($this->handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-06',
));
$vacations = array_filter($result, static fn ($d) => $d->type === CalendarEntryType::VACATION->value);
self::assertCount(5, $vacations);
}
private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: $date,
endDate: $date,
label: $label,
));
return $calendar;
}
private function createCalendarWithVacation(
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
string $label,
): SchoolCalendar {
$tenantId = TenantId::fromString(self::TENANT_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: $startDate,
endDate: $endDate,
label: $label,
));
return $calendar;
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetScheduleSlots;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Query\GetScheduleSlots\GetScheduleSlotsHandler;
use App\Scolarite\Application\Query\GetScheduleSlots\GetScheduleSlotsQuery;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetScheduleSlotsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_A = '550e8400-e29b-41d4-a716-446655440020';
private const string CLASS_B = '550e8400-e29b-41d4-a716-446655440021';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_A = '550e8400-e29b-41d4-a716-446655440010';
private const string TEACHER_B = '550e8400-e29b-41d4-a716-446655440011';
private InMemoryScheduleSlotRepository $repository;
private GetScheduleSlotsHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$this->handler = new GetScheduleSlotsHandler($this->repository);
}
#[Test]
public function returnsSlotsFilteredByClass(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
classId: self::CLASS_A,
));
self::assertCount(1, $result);
self::assertSame(self::CLASS_A, $result[0]->classId);
}
#[Test]
public function returnsSlotsFilteredByTeacher(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_B, day: DayOfWeek::TUESDAY);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
teacherId: self::TEACHER_A,
));
self::assertCount(1, $result);
self::assertSame(self::TEACHER_A, $result[0]->teacherId);
}
#[Test]
public function returnsSlotsFilteredByClassAndTeacher(): void
{
// Classe A, enseignant A
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
// Classe A, enseignant B
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_B, day: DayOfWeek::TUESDAY);
// Classe B, enseignant A
$this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A, day: DayOfWeek::WEDNESDAY);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
classId: self::CLASS_A,
teacherId: self::TEACHER_A,
));
self::assertCount(1, $result);
self::assertSame(self::CLASS_A, $result[0]->classId);
self::assertSame(self::TEACHER_A, $result[0]->teacherId);
}
#[Test]
public function returnsEmptyWhenClassAndTeacherDontMatch(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
classId: self::CLASS_A,
teacherId: self::TEACHER_B,
));
self::assertCount(0, $result);
}
#[Test]
public function returnsEmptyWhenNoFilters(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
));
self::assertCount(0, $result);
}
#[Test]
public function returnsEmptyForInvalidClassIdUuid(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
classId: 'not-a-valid-uuid',
));
self::assertCount(0, $result);
}
#[Test]
public function returnsEmptyForInvalidTeacherIdUuid(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
teacherId: 'invalid',
));
self::assertCount(0, $result);
}
#[Test]
public function teacherFilterReturnsAllClassesForTeacher(): void
{
$this->createSlot(classId: self::CLASS_A, teacherId: self::TEACHER_A);
$this->createSlot(classId: self::CLASS_B, teacherId: self::TEACHER_A, day: DayOfWeek::TUESDAY);
$result = ($this->handler)(new GetScheduleSlotsQuery(
tenantId: self::TENANT_ID,
teacherId: self::TEACHER_A,
));
self::assertCount(2, $result);
}
private function createSlot(
string $classId,
string $teacherId,
DayOfWeek $day = DayOfWeek::MONDAY,
): ScheduleSlot {
$slot = ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString($teacherId),
dayOfWeek: $day,
timeSlot: new TimeSlot('08:00', '09:00'),
room: null,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
$this->repository->save($slot);
return $slot;
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\CoursCree;
use App\Scolarite\Domain\Event\CoursModifie;
use App\Scolarite\Domain\Event\CoursSupprime;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ScheduleSlotTest 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 SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function creerCreatesSlotWithCorrectProperties(): void
{
$slot = $this->createSlot();
self::assertTrue($slot->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
self::assertTrue($slot->classId->equals(ClassId::fromString(self::CLASS_ID)));
self::assertTrue($slot->subjectId->equals(SubjectId::fromString(self::SUBJECT_ID)));
self::assertTrue($slot->teacherId->equals(UserId::fromString(self::TEACHER_ID)));
self::assertSame(DayOfWeek::MONDAY, $slot->dayOfWeek);
self::assertSame('08:00', $slot->timeSlot->startTime);
self::assertSame('09:00', $slot->timeSlot->endTime);
self::assertNull($slot->room);
self::assertTrue($slot->isRecurring);
}
#[Test]
public function creerRecordsCoursCreeEvent(): void
{
$slot = $this->createSlot();
$events = $slot->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CoursCree::class, $events[0]);
self::assertTrue($slot->id->equals($events[0]->slotId));
}
#[Test]
public function creerWithRoomSetsRoom(): void
{
$slot = $this->createSlot(room: 'Salle 101');
self::assertSame('Salle 101', $slot->room);
}
#[Test]
public function modifierUpdatesProperties(): void
{
$slot = $this->createSlot();
$slot->pullDomainEvents();
$newSubjectId = SubjectId::fromString('550e8400-e29b-41d4-a716-446655440031');
$newTeacherId = UserId::fromString('550e8400-e29b-41d4-a716-446655440011');
$newTimeSlot = new TimeSlot('10:00', '11:00');
$now = new DateTimeImmutable('2026-03-02 15:00:00');
$newClassId = ClassId::fromString('550e8400-e29b-41d4-a716-446655440021');
$slot->modifier(
classId: $newClassId,
subjectId: $newSubjectId,
teacherId: $newTeacherId,
dayOfWeek: DayOfWeek::TUESDAY,
timeSlot: $newTimeSlot,
room: 'Salle 202',
at: $now,
);
self::assertTrue($slot->classId->equals($newClassId));
self::assertTrue($slot->subjectId->equals($newSubjectId));
self::assertTrue($slot->teacherId->equals($newTeacherId));
self::assertSame(DayOfWeek::TUESDAY, $slot->dayOfWeek);
self::assertSame('10:00', $slot->timeSlot->startTime);
self::assertSame('11:00', $slot->timeSlot->endTime);
self::assertSame('Salle 202', $slot->room);
self::assertEquals($now, $slot->updatedAt);
}
#[Test]
public function modifierRecordsCoursModifieEvent(): void
{
$slot = $this->createSlot();
$slot->pullDomainEvents();
$now = new DateTimeImmutable('2026-03-02 15:00:00');
$slot->modifier(
classId: $slot->classId,
subjectId: $slot->subjectId,
teacherId: $slot->teacherId,
dayOfWeek: DayOfWeek::TUESDAY,
timeSlot: new TimeSlot('10:00', '11:00'),
room: null,
at: $now,
);
$events = $slot->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CoursModifie::class, $events[0]);
self::assertTrue($slot->id->equals($events[0]->slotId));
}
#[Test]
public function supprimerRecordsCoursSupprime(): void
{
$slot = $this->createSlot();
$slot->pullDomainEvents();
$now = new DateTimeImmutable('2026-03-02 15:00:00');
$slot->supprimer($now);
$events = $slot->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CoursSupprime::class, $events[0]);
self::assertTrue($slot->id->equals($events[0]->slotId));
}
#[Test]
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
{
$id = ScheduleSlotId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$classId = ClassId::fromString(self::CLASS_ID);
$subjectId = SubjectId::fromString(self::SUBJECT_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$timeSlot = new TimeSlot('14:00', '15:30');
$createdAt = new DateTimeImmutable('2026-03-01 10:00:00');
$updatedAt = new DateTimeImmutable('2026-03-02 14:00:00');
$slot = ScheduleSlot::reconstitute(
id: $id,
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
dayOfWeek: DayOfWeek::FRIDAY,
timeSlot: $timeSlot,
room: 'Salle 305',
isRecurring: false,
createdAt: $createdAt,
updatedAt: $updatedAt,
);
self::assertTrue($slot->id->equals($id));
self::assertTrue($slot->tenantId->equals($tenantId));
self::assertTrue($slot->classId->equals($classId));
self::assertTrue($slot->subjectId->equals($subjectId));
self::assertTrue($slot->teacherId->equals($teacherId));
self::assertSame(DayOfWeek::FRIDAY, $slot->dayOfWeek);
self::assertSame('14:00', $slot->timeSlot->startTime);
self::assertSame('15:30', $slot->timeSlot->endTime);
self::assertSame('Salle 305', $slot->room);
self::assertFalse($slot->isRecurring);
self::assertEquals($createdAt, $slot->createdAt);
self::assertEquals($updatedAt, $slot->updatedAt);
self::assertEmpty($slot->pullDomainEvents());
}
private function createSlot(?string $room = null): ScheduleSlot
{
return ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
dayOfWeek: DayOfWeek::MONDAY,
timeSlot: new TimeSlot('08:00', '09:00'),
room: $room,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
use App\Scolarite\Domain\Exception\CreneauHoraireInvalideException;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TimeSlotTest extends TestCase
{
#[Test]
public function createsValidTimeSlot(): void
{
$timeSlot = new TimeSlot('08:00', '09:00');
self::assertSame('08:00', $timeSlot->startTime);
self::assertSame('09:00', $timeSlot->endTime);
}
#[Test]
public function throwsWhenEndTimeBeforeStartTime(): void
{
$this->expectException(CreneauHoraireInvalideException::class);
new TimeSlot('10:00', '09:00');
}
#[Test]
public function throwsWhenEndTimeEqualsStartTime(): void
{
$this->expectException(CreneauHoraireInvalideException::class);
new TimeSlot('08:00', '08:00');
}
#[Test]
public function throwsWhenDurationLessThanFiveMinutes(): void
{
$this->expectException(CreneauHoraireInvalideException::class);
new TimeSlot('08:00', '08:04');
}
#[Test]
public function acceptsFiveMinuteDuration(): void
{
$timeSlot = new TimeSlot('08:00', '08:05');
self::assertSame('08:00', $timeSlot->startTime);
self::assertSame('08:05', $timeSlot->endTime);
}
#[Test]
public function overlapsReturnsTrueWhenTimesOverlap(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('08:30', '09:30');
self::assertTrue($slot1->overlaps($slot2));
self::assertTrue($slot2->overlaps($slot1));
}
#[Test]
public function overlapsReturnsFalseWhenTimesDoNotOverlap(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('09:00', '10:00');
self::assertFalse($slot1->overlaps($slot2));
self::assertFalse($slot2->overlaps($slot1));
}
#[Test]
public function overlapsReturnsTrueWhenOneContainsOther(): void
{
$slot1 = new TimeSlot('08:00', '12:00');
$slot2 = new TimeSlot('09:00', '10:00');
self::assertTrue($slot1->overlaps($slot2));
self::assertTrue($slot2->overlaps($slot1));
}
#[Test]
public function overlapsReturnsFalseWhenAdjacent(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('09:00', '10:00');
self::assertFalse($slot1->overlaps($slot2));
}
#[Test]
public function equalsReturnsTrueForSameTimes(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('08:00', '09:00');
self::assertTrue($slot1->equals($slot2));
}
#[Test]
public function equalsReturnsFalseForDifferentTimes(): void
{
$slot1 = new TimeSlot('08:00', '09:00');
$slot2 = new TimeSlot('08:00', '10:00');
self::assertFalse($slot1->equals($slot2));
}
#[Test]
public function durationInMinutesReturnsCorrectValue(): void
{
$timeSlot = new TimeSlot('08:00', '09:30');
self::assertSame(90, $timeSlot->durationInMinutes());
}
}

View File

@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Service;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ScheduleConflictDetectorTest 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 SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $repository;
private ScheduleConflictDetector $detector;
protected function setUp(): void
{
$this->repository = new InMemoryScheduleSlotRepository();
$this->detector = new ScheduleConflictDetector($this->repository);
}
#[Test]
public function detectsClassConflict(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
// Même classe, même créneau, enseignant différent
$newSlot = $this->createSlot(
teacherId: '550e8400-e29b-41d4-a716-446655440011',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertNotEmpty($conflicts);
$types = array_map(static fn ($c) => $c->type, $conflicts);
self::assertContains('class', $types);
}
#[Test]
public function noClassConflictWhenDifferentClass(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: '550e8400-e29b-41d4-a716-446655440011',
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertEmpty($conflicts);
}
#[Test]
public function detectsTeacherConflict(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertNotEmpty($conflicts);
self::assertSame('teacher', $conflicts[0]->type);
}
#[Test]
public function noConflictWhenDifferentDay(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::TUESDAY,
startTime: '08:00',
endTime: '09:00',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertEmpty($conflicts);
}
#[Test]
public function noConflictWhenAdjacentTimes(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '09:00',
endTime: '10:00',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertEmpty($conflicts);
}
#[Test]
public function detectsRoomConflict(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
room: 'Salle 101',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: '550e8400-e29b-41d4-a716-446655440011',
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
room: 'Salle 101',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertNotEmpty($conflicts);
self::assertSame('room', $conflicts[0]->type);
}
#[Test]
public function noRoomConflictWhenNoRoom(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: '550e8400-e29b-41d4-a716-446655440011',
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
self::assertEmpty($conflicts);
}
#[Test]
public function excludesCurrentSlotWhenUpdating(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
);
$this->repository->save($existingSlot);
$conflicts = $this->detector->detectConflicts($existingSlot, $tenantId, $existingSlot->id);
self::assertEmpty($conflicts);
}
#[Test]
public function detectsBothTeacherAndRoomConflicts(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$existingSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:00',
endTime: '09:00',
room: 'Salle 101',
);
$this->repository->save($existingSlot);
$newSlot = $this->createSlot(
teacherId: self::TEACHER_ID,
classId: '550e8400-e29b-41d4-a716-446655440021',
dayOfWeek: DayOfWeek::MONDAY,
startTime: '08:30',
endTime: '09:30',
room: 'Salle 101',
);
$conflicts = $this->detector->detectConflicts($newSlot, $tenantId);
$types = array_map(static fn ($c) => $c->type, $conflicts);
self::assertContains('teacher', $types);
self::assertContains('room', $types);
}
private function createSlot(
string $teacherId = self::TEACHER_ID,
string $classId = self::CLASS_ID,
DayOfWeek $dayOfWeek = DayOfWeek::MONDAY,
string $startTime = '08:00',
string $endTime = '09:00',
?string $room = null,
): ScheduleSlot {
return ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString($teacherId),
dayOfWeek: $dayOfWeek,
timeSlot: new TimeSlot($startTime, $endTime),
room: $room,
isRecurring: true,
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
}
}