feat: Configurer les jours fériés et vacances du calendrier scolaire

Les administrateurs d'établissement avaient besoin de gérer le calendrier
scolaire (FR80) pour que l'EDT et les devoirs respectent automatiquement
les jours non travaillés. Sans cette configuration centralisée, chaque
module devait gérer indépendamment les contraintes de dates.

Le calendrier s'appuie sur l'API data.education.gouv.fr pour importer
les vacances officielles par zone (A/B/C) et calcule les 11 jours fériés
français (dont les fêtes mobiles liées à Pâques). Les enseignants sont
notifiés par email lors de l'ajout d'une journée pédagogique. Un query
IsSchoolDay et une validation des dates d'échéance de devoirs permettent
aux autres modules de s'intégrer sans couplage direct.
This commit is contained in:
2026-02-18 10:16:28 +01:00
parent 0951322d71
commit e06fd5424d
60 changed files with 7698 additions and 1 deletions

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\AddPedagogicalDay;
use App\Administration\Application\Command\AddPedagogicalDay\AddPedagogicalDayCommand;
use App\Administration\Application\Command\AddPedagogicalDay\AddPedagogicalDayHandler;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
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\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AddPedagogicalDayHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemorySchoolCalendarRepository $repository;
private AddPedagogicalDayHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemorySchoolCalendarRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-17 10:00:00');
}
};
$this->handler = new AddPedagogicalDayHandler(
calendarRepository: $this->repository,
clock: $clock,
);
}
#[Test]
public function itAddsPedagogicalDayToExistingCalendar(): void
{
$this->seedCalendar();
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-14',
label: 'Formation enseignants',
);
$calendar = ($this->handler)($command);
$entries = $calendar->entries();
$pedagogicalDays = array_filter(
$entries,
static fn ($e) => $e->type === CalendarEntryType::PEDAGOGICAL_DAY,
);
self::assertCount(1, $pedagogicalDays);
$day = array_values($pedagogicalDays)[0];
self::assertSame('Formation enseignants', $day->label);
self::assertSame('2025-03-14', $day->startDate->format('Y-m-d'));
}
#[Test]
public function itCreatesNewCalendarIfNoneExists(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-14',
label: 'Formation enseignants',
);
$calendar = ($this->handler)($command);
self::assertCount(1, $calendar->entries());
}
#[Test]
public function itRecordsJourneePedagogiqueAjouteeEvent(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-14',
label: 'Formation enseignants',
);
$calendar = ($this->handler)($command);
$events = $calendar->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(JourneePedagogiqueAjoutee::class, $events[0]);
self::assertSame('Formation enseignants', $events[0]->label);
}
#[Test]
public function itSavesCalendarWithPedagogicalDay(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-14',
label: 'Formation',
description: 'Journée de formation continue',
);
($this->handler)($command);
$saved = $this->repository->getByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertCount(1, $saved->entries());
self::assertSame('Journée de formation continue', $saved->entries()[0]->description);
}
#[Test]
public function itRejectsMalformedDate(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: 'not-a-date',
label: 'Formation',
);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('La date doit être au format YYYY-MM-DD.');
($this->handler)($command);
}
#[Test]
public function itRejectsImpossibleCalendarDate(): void
{
$command = new AddPedagogicalDayCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-06-31',
label: 'Formation',
);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('La date n\'existe pas dans le calendrier.');
($this->handler)($command);
}
private function seedCalendar(): void
{
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$this->repository->save($calendar);
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ConfigureCalendar;
use App\Administration\Application\Command\ConfigureCalendar\ConfigureCalendarCommand;
use App\Administration\Application\Command\ConfigureCalendar\ConfigureCalendarHandler;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Administration\Infrastructure\Service\FrenchPublicHolidaysCalculator;
use App\Administration\Infrastructure\Service\JsonOfficialCalendarProvider;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use function sys_get_temp_dir;
final class ConfigureCalendarHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemorySchoolCalendarRepository $repository;
private ConfigureCalendarHandler $handler;
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/classeo-handler-test-' . uniqid();
$this->repository = new InMemorySchoolCalendarRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-17 10:00:00');
}
};
$this->handler = new ConfigureCalendarHandler(
calendarRepository: $this->repository,
calendarProvider: new JsonOfficialCalendarProvider(
dataDirectory: $this->tempDir,
httpClient: new MockHttpClient($this->mockApiResponse()),
holidaysCalculator: new FrenchPublicHolidaysCalculator(),
logger: new NullLogger(),
),
clock: $clock,
);
}
protected function tearDown(): void
{
$files = glob($this->tempDir . '/*');
if ($files !== false) {
foreach ($files as $file) {
unlink($file);
}
}
if (is_dir($this->tempDir)) {
rmdir($this->tempDir);
}
}
#[Test]
public function itConfiguresCalendarWithZoneA(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'A',
academicYear: '2024-2025',
);
$calendar = ($this->handler)($command);
self::assertSame(SchoolZone::A, $calendar->zone);
self::assertNotEmpty($calendar->entries());
}
#[Test]
public function itImportsHolidaysAndVacations(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'B',
academicYear: '2024-2025',
);
$calendar = ($this->handler)($command);
$holidays = array_filter(
$calendar->entries(),
static fn ($e) => $e->type === CalendarEntryType::HOLIDAY,
);
$vacations = array_filter(
$calendar->entries(),
static fn ($e) => $e->type === CalendarEntryType::VACATION,
);
self::assertNotEmpty($holidays);
self::assertNotEmpty($vacations);
}
#[Test]
public function itSavesCalendarToRepository(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'C',
academicYear: '2024-2025',
);
($this->handler)($command);
$saved = $this->repository->findByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNotNull($saved);
self::assertSame(SchoolZone::C, $saved->zone);
}
#[Test]
public function itReconfiguresExistingCalendar(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'A',
academicYear: '2024-2025',
);
($this->handler)($command);
$commandB = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'B',
academicYear: '2024-2025',
);
$calendar = ($this->handler)($commandB);
self::assertSame(SchoolZone::B, $calendar->zone);
}
#[Test]
public function itRecordsCalendrierConfigureEvent(): void
{
$command = new ConfigureCalendarCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
zone: 'A',
academicYear: '2024-2025',
);
$calendar = ($this->handler)($command);
$events = $calendar->pullDomainEvents();
self::assertNotEmpty($events);
}
private function mockApiResponse(): MockResponse
{
$records = [
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-21T23:00:00+00:00', 'end_date' => '2025-03-09T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-18T22:00:00+00:00', 'end_date' => '2025-05-04T22:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-07T23:00:00+00:00', 'end_date' => '2025-02-23T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-04T22:00:00+00:00', 'end_date' => '2025-04-20T22:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-14T23:00:00+00:00', 'end_date' => '2025-03-02T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-11T22:00:00+00:00', 'end_date' => '2025-04-27T22:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone C'],
];
return new MockResponse(json_encode(['results' => $records], JSON_THROW_ON_ERROR), ['http_code' => 200]);
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\IsSchoolDay;
use App\Administration\Application\Query\IsSchoolDay\IsSchoolDayHandler;
use App\Administration\Application\Query\IsSchoolDay\IsSchoolDayQuery;
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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class IsSchoolDayHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemorySchoolCalendarRepository $repository;
private IsSchoolDayHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemorySchoolCalendarRepository();
$this->handler = new IsSchoolDayHandler(
calendarRepository: $this->repository,
);
}
#[Test]
public function weekdayWithNoCalendarIsSchoolDay(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-10', // Lundi
);
self::assertTrue(($this->handler)($query));
}
#[Test]
public function saturdayIsNotSchoolDay(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-15', // Samedi
);
self::assertFalse(($this->handler)($query));
}
#[Test]
public function sundayIsNotSchoolDay(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-03-16', // Dimanche
);
self::assertFalse(($this->handler)($query));
}
#[Test]
public function holidayIsNotSchoolDay(): void
{
$this->seedCalendarWithHoliday('2025-05-01', 'Fête du travail');
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-05-01', // Jeudi férié
);
self::assertFalse(($this->handler)($query));
}
#[Test]
public function vacationDayIsNotSchoolDay(): void
{
$this->seedCalendarWithVacation('2025-02-08', '2025-02-23', 'Vacances hiver');
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-02-10', // Lundi en vacances
);
self::assertFalse(($this->handler)($query));
}
#[Test]
public function regularWeekdayWithCalendarIsSchoolDay(): void
{
$this->seedCalendarWithHoliday('2025-05-01', 'Fête du travail');
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-05-02', // Vendredi normal
);
self::assertTrue(($this->handler)($query));
}
#[Test]
public function itRejectsMalformedDate(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: 'invalid-date',
);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('La date doit être au format YYYY-MM-DD.');
($this->handler)($query);
}
#[Test]
public function itRejectsImpossibleCalendarDate(): void
{
$query = new IsSchoolDayQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
date: '2025-02-30',
);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('La date n\'existe pas dans le calendrier.');
($this->handler)($query);
}
private function seedCalendarWithHoliday(string $date, string $label): void
{
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable($date),
endDate: new DateTimeImmutable($date),
label: $label,
));
$this->repository->save($calendar);
}
private function seedCalendarWithVacation(string $start, string $end, string $label): void
{
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable($start),
endDate: new DateTimeImmutable($end),
label: $label,
));
$this->repository->save($calendar);
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\ValidateHomeworkDueDate;
use App\Administration\Application\Query\ValidateHomeworkDueDate\ValidateHomeworkDueDateHandler;
use App\Administration\Application\Query\ValidateHomeworkDueDate\ValidateHomeworkDueDateQuery;
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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ValidateHomeworkDueDateHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemorySchoolCalendarRepository $repository;
private ValidateHomeworkDueDateHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemorySchoolCalendarRepository();
$this->handler = new ValidateHomeworkDueDateHandler(
calendarRepository: $this->repository,
);
}
#[Test]
public function weekdayWithNoCalendarIsValid(): void
{
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-03-10', // Lundi
));
self::assertTrue($result->valid);
self::assertNull($result->reason);
self::assertSame([], $result->warnings);
}
#[Test]
public function weekendIsInvalid(): void
{
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-03-15', // Samedi
));
self::assertFalse($result->valid);
self::assertStringContainsString('weekend', $result->reason);
}
#[Test]
public function holidayIsInvalid(): void
{
$this->seedCalendarWithEntries(
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-05-01'),
endDate: new DateTimeImmutable('2025-05-01'),
label: 'Fête du travail',
),
);
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-05-01',
));
self::assertFalse($result->valid);
self::assertStringContainsString('férié', $result->reason);
}
#[Test]
public function vacationDayIsInvalid(): void
{
$this->seedCalendarWithEntries(
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-02-08'),
endDate: new DateTimeImmutable('2025-02-23'),
label: 'Vacances hiver',
),
);
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-02-10', // Lundi en vacances
));
self::assertFalse($result->valid);
self::assertStringContainsString('vacances', $result->reason);
}
#[Test]
public function returnDayFromVacationIsValidWithWarning(): void
{
$this->seedCalendarWithEntries(
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-02-08'),
endDate: new DateTimeImmutable('2025-02-23'),
label: 'Vacances hiver',
),
);
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-02-24', // Lundi retour de vacances
));
self::assertTrue($result->valid);
self::assertCount(1, $result->warnings);
self::assertStringContainsString('retour de vacances', $result->warnings[0]);
}
#[Test]
public function normalSchoolDayIsValid(): void
{
$this->seedCalendarWithEntries(
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-05-01'),
endDate: new DateTimeImmutable('2025-05-01'),
label: 'Fête du travail',
),
);
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-05-02', // Vendredi normal
));
self::assertTrue($result->valid);
self::assertSame([], $result->warnings);
}
#[Test]
public function malformedDateIsInvalid(): void
{
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: 'not-a-date',
));
self::assertFalse($result->valid);
self::assertStringContainsString('YYYY-MM-DD', $result->reason);
}
#[Test]
public function impossibleCalendarDateIsInvalid(): void
{
$result = ($this->handler)(new ValidateHomeworkDueDateQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
dueDate: '2025-02-30',
));
self::assertFalse($result->valid);
self::assertStringContainsString('n\'existe pas', $result->reason);
}
private function seedCalendarWithEntries(CalendarEntry ...$entries): void
{
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
foreach ($entries as $entry) {
$calendar->ajouterEntree($entry);
}
$this->repository->save($calendar);
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Exception\CalendrierDatesInvalidesException;
use App\Administration\Domain\Exception\CalendrierLabelInvalideException;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CalendarEntryTest extends TestCase
{
#[Test]
public function creationValide(): void
{
$entry = $this->createHoliday();
self::assertSame(CalendarEntryType::HOLIDAY, $entry->type);
self::assertSame('Toussaint', $entry->label);
self::assertSame('2024-11-01', $entry->startDate->format('Y-m-d'));
self::assertSame('2024-11-01', $entry->endDate->format('Y-m-d'));
self::assertNull($entry->description);
}
#[Test]
public function creationAvecDescription(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::PEDAGOGICAL_DAY,
startDate: new DateTimeImmutable('2025-03-14'),
endDate: new DateTimeImmutable('2025-03-14'),
label: 'Formation enseignants',
description: 'Journée de formation continue',
);
self::assertSame('Journée de formation continue', $entry->description);
}
#[Test]
public function creationPeriodeVacances(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2024-10-19'),
endDate: new DateTimeImmutable('2024-11-03'),
label: 'Vacances de la Toussaint',
);
self::assertSame(CalendarEntryType::VACATION, $entry->type);
self::assertSame('2024-10-19', $entry->startDate->format('Y-m-d'));
self::assertSame('2024-11-03', $entry->endDate->format('Y-m-d'));
}
#[Test]
public function labelEstTrimme(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: ' Toussaint ',
);
self::assertSame('Toussaint', $entry->label);
}
#[Test]
public function dateFinAvantDebutLeveException(): void
{
$this->expectException(CalendrierDatesInvalidesException::class);
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-02'),
endDate: new DateTimeImmutable('2024-11-01'),
label: 'Invalid',
);
}
#[Test]
public function labelVideLeveException(): void
{
$this->expectException(CalendrierLabelInvalideException::class);
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: '',
);
}
#[Test]
public function labelTropCourtLeveException(): void
{
$this->expectException(CalendrierLabelInvalideException::class);
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: 'A',
);
}
#[Test]
public function labelTropLongLeveException(): void
{
$this->expectException(CalendrierLabelInvalideException::class);
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: str_repeat('A', 101),
);
}
#[Test]
public function couvreRetourneTruePourDateDansLaPeriode(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2024-10-19'),
endDate: new DateTimeImmutable('2024-11-03'),
label: 'Vacances Toussaint',
);
self::assertTrue($entry->couvre(new DateTimeImmutable('2024-10-19')));
self::assertTrue($entry->couvre(new DateTimeImmutable('2024-10-25')));
self::assertTrue($entry->couvre(new DateTimeImmutable('2024-11-03')));
}
#[Test]
public function couvreRetourneFalsePourDateHorsPeriode(): void
{
$entry = new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2024-10-19'),
endDate: new DateTimeImmutable('2024-11-03'),
label: 'Vacances Toussaint',
);
self::assertFalse($entry->couvre(new DateTimeImmutable('2024-10-18')));
self::assertFalse($entry->couvre(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function couvreJourUnique(): void
{
$entry = $this->createHoliday();
self::assertTrue($entry->couvre(new DateTimeImmutable('2024-11-01')));
self::assertFalse($entry->couvre(new DateTimeImmutable('2024-10-31')));
self::assertFalse($entry->couvre(new DateTimeImmutable('2024-11-02')));
}
private function createHoliday(): CalendarEntry
{
return new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2024-11-01'),
endDate: new DateTimeImmutable('2024-11-01'),
label: 'Toussaint',
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CalendarEntryTypeTest extends TestCase
{
#[Test]
public function chaqueTypeAUnLabel(): void
{
foreach (CalendarEntryType::cases() as $type) {
self::assertNotEmpty($type->label());
}
}
#[Test]
public function labelsAttendus(): void
{
self::assertSame('Jour férié', CalendarEntryType::HOLIDAY->label());
self::assertSame('Vacances scolaires', CalendarEntryType::VACATION->label());
self::assertSame('Journée pédagogique', CalendarEntryType::PEDAGOGICAL_DAY->label());
self::assertSame('Pont', CalendarEntryType::BRIDGE->label());
self::assertSame('Fermeture exceptionnelle', CalendarEntryType::EXCEPTIONAL_CLOSURE->label());
}
#[Test]
public function backedValuesConsistantes(): void
{
self::assertSame('holiday', CalendarEntryType::HOLIDAY->value);
self::assertSame('vacation', CalendarEntryType::VACATION->value);
self::assertSame('pedagogical', CalendarEntryType::PEDAGOGICAL_DAY->value);
self::assertSame('bridge', CalendarEntryType::BRIDGE->value);
self::assertSame('closure', CalendarEntryType::EXCEPTIONAL_CLOSURE->value);
}
}

View File

@@ -0,0 +1,420 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Event\CalendrierConfigure;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
use App\Administration\Domain\Exception\CalendrierEntreeNonTrouveeException;
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\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use function sprintf;
final class SchoolCalendarTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function initialiserCreeCalendrierVide(): void
{
$calendar = $this->createCalendar();
self::assertTrue($calendar->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
self::assertTrue($calendar->academicYearId->equals(AcademicYearId::fromString(self::ACADEMIC_YEAR_ID)));
self::assertNull($calendar->zone);
self::assertEmpty($calendar->entries());
}
#[Test]
public function configurerZoneDefInitLaZone(): void
{
$calendar = $this->createCalendar();
$calendar->configurerZone(SchoolZone::A);
self::assertSame(SchoolZone::A, $calendar->zone);
}
#[Test]
public function configurerZonePeutEtreChangee(): void
{
$calendar = $this->createCalendar();
$calendar->configurerZone(SchoolZone::A);
$calendar->configurerZone(SchoolZone::C);
self::assertSame(SchoolZone::C, $calendar->zone);
}
#[Test]
public function ajouterEntreeAjouteAuCalendrier(): void
{
$calendar = $this->createCalendar();
$entry = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$calendar->ajouterEntree($entry);
self::assertCount(1, $calendar->entries());
self::assertSame($entry, $calendar->entries()[0]);
}
#[Test]
public function ajouterPlusieursEntrees(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'));
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-11', '2024-11-11', 'Armistice'));
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::VACATION, '2024-12-21', '2025-01-05', 'Vacances de Noël'));
self::assertCount(3, $calendar->entries());
}
#[Test]
public function supprimerEntreeRetireEntreeExistante(): void
{
$calendar = $this->createCalendar();
$entry = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$calendar->ajouterEntree($entry);
$calendar->supprimerEntree($entry->id);
self::assertEmpty($calendar->entries());
}
#[Test]
public function supprimerEntreeInexistanteLeveException(): void
{
$calendar = $this->createCalendar();
$this->expectException(CalendrierEntreeNonTrouveeException::class);
$calendar->supprimerEntree(CalendarEntryId::generate());
}
#[Test]
public function viderEntreesSupprimeTout(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'));
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-11', '2024-11-11', 'Armistice'));
$calendar->viderEntrees();
self::assertEmpty($calendar->entries());
}
#[Test]
public function estJourOuvreRetourneTruePourJourSemaine(): void
{
$calendar = $this->createCalendar();
// Lundi 4 novembre 2024
self::assertTrue($calendar->estJourOuvre(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function estJourOuvreRetourneFalsePourSamedi(): void
{
$calendar = $this->createCalendar();
// Samedi 2 novembre 2024
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2024-11-02')));
}
#[Test]
public function estJourOuvreRetourneFalsePourDimanche(): void
{
$calendar = $this->createCalendar();
// Dimanche 3 novembre 2024
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2024-11-03')));
}
#[Test]
public function estJourOuvreRetourneFalsePourJourFerie(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'),
);
// Vendredi 1er novembre 2024 (Toussaint)
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2024-11-01')));
}
#[Test]
public function estJourOuvreRetourneFalsePendantVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
// Mercredi 23 octobre 2024 (en plein dans les vacances)
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2024-10-23')));
}
#[Test]
public function estJourOuvreRetourneFalsePourJourneePedagogique(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::PEDAGOGICAL_DAY, '2025-03-14', '2025-03-14', 'Formation'),
);
// Vendredi 14 mars 2025
self::assertFalse($calendar->estJourOuvre(new DateTimeImmutable('2025-03-14')));
}
#[Test]
public function estJourOuvreRetourneTrueApresVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
// Lundi 4 novembre 2024 (jour de reprise)
self::assertTrue($calendar->estJourOuvre(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function trouverEntreePourDateRetourneEntreeCorrespondante(): void
{
$calendar = $this->createCalendar();
$holiday = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$calendar->ajouterEntree($holiday);
$found = $calendar->trouverEntreePourDate(new DateTimeImmutable('2024-11-01'));
self::assertNotNull($found);
self::assertSame('Toussaint', $found->label);
}
#[Test]
public function trouverEntreePourDateRetourneNullSiAucune(): void
{
$calendar = $this->createCalendar();
$found = $calendar->trouverEntreePourDate(new DateTimeImmutable('2024-11-01'));
self::assertNull($found);
}
#[Test]
public function estEnVacancesRetourneTruePendantVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
self::assertTrue($calendar->estEnVacances(new DateTimeImmutable('2024-10-25')));
}
#[Test]
public function estEnVacancesRetourneFalseHorsVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
self::assertFalse($calendar->estEnVacances(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function estEnVacancesRetourneFalsePourJourFerie(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'),
);
self::assertFalse($calendar->estEnVacances(new DateTimeImmutable('2024-11-01')));
}
#[Test]
public function estJourRetourVacancesRetourneTruePourJourApresFinVacances(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
// 4 novembre = lendemain de la fin des vacances (3 novembre)
self::assertTrue($calendar->estJourRetourVacances(new DateTimeImmutable('2024-11-04')));
}
#[Test]
public function estJourRetourVacancesRetourneFalsePourJourNormal(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree(
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
);
self::assertFalse($calendar->estJourRetourVacances(new DateTimeImmutable('2024-11-05')));
}
#[Test]
public function configurerDefinitZoneEtImporteEntreesAvecEvenement(): void
{
$calendar = $this->createCalendar();
$entries = [
$this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'),
$this->createEntry(CalendarEntryType::VACATION, '2024-10-19', '2024-11-03', 'Vacances Toussaint'),
];
$at = new DateTimeImmutable('2026-02-17 10:00:00');
$calendar->configurer(SchoolZone::A, $entries, $at);
self::assertSame(SchoolZone::A, $calendar->zone);
self::assertCount(2, $calendar->entries());
$events = $calendar->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(CalendrierConfigure::class, $events[0]);
self::assertSame(SchoolZone::A, $events[0]->zone);
self::assertSame(2, $events[0]->nombreEntrees);
$expectedAggregateId = Uuid::uuid5(
Uuid::NAMESPACE_DNS,
sprintf('school-calendar:%s:%s', self::TENANT_ID, self::ACADEMIC_YEAR_ID),
);
self::assertTrue($events[0]->aggregateId()->equals($expectedAggregateId));
}
#[Test]
public function configurerPreserveJourneesPedagogiques(): void
{
$calendar = $this->createCalendar();
$pedaEntry = $this->createEntry(CalendarEntryType::PEDAGOGICAL_DAY, '2025-03-14', '2025-03-14', 'Formation');
$calendar->ajouterEntree($pedaEntry);
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint'));
$entries = [
$this->createEntry(CalendarEntryType::VACATION, '2024-12-21', '2025-01-05', 'Vacances de Noël'),
];
$calendar->configurer(SchoolZone::A, $entries, new DateTimeImmutable());
// 1 preserved pedagogical day + 1 new vacation = 2
self::assertCount(2, $calendar->entries());
$types = array_map(static fn (CalendarEntry $e) => $e->type, $calendar->entries());
self::assertContains(CalendarEntryType::PEDAGOGICAL_DAY, $types);
self::assertContains(CalendarEntryType::VACATION, $types);
self::assertNotContains(CalendarEntryType::HOLIDAY, $types);
}
#[Test]
public function configurerReemplaceEntreesExistantes(): void
{
$calendar = $this->createCalendar();
$calendar->ajouterEntree($this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Ancienne'));
$entries = [
$this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-11', '2024-11-11', 'Armistice'),
];
$calendar->configurer(SchoolZone::B, $entries, new DateTimeImmutable());
self::assertCount(1, $calendar->entries());
self::assertSame('Armistice', $calendar->entries()[0]->label);
}
#[Test]
public function ajouterJourneePedagogiqueEmetEvenement(): void
{
$calendar = $this->createCalendar();
$entry = $this->createEntry(CalendarEntryType::PEDAGOGICAL_DAY, '2025-03-14', '2025-03-14', 'Formation enseignants');
$at = new DateTimeImmutable('2026-02-17 10:00:00');
$calendar->ajouterJourneePedagogique($entry, $at);
self::assertCount(1, $calendar->entries());
$events = $calendar->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(JourneePedagogiqueAjoutee::class, $events[0]);
self::assertSame('Formation enseignants', $events[0]->label);
self::assertSame('2025-03-14', $events[0]->date->format('Y-m-d'));
$expectedAggregateId = Uuid::uuid5(
Uuid::NAMESPACE_DNS,
sprintf('school-calendar:%s:%s', self::TENANT_ID, self::ACADEMIC_YEAR_ID),
);
self::assertTrue($events[0]->aggregateId()->equals($expectedAggregateId));
}
#[Test]
public function ajouterJourneePedagogiqueRefuseTypeDifferent(): void
{
$calendar = $this->createCalendar();
$entry = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$this->expectException(InvalidArgumentException::class);
$calendar->ajouterJourneePedagogique($entry, new DateTimeImmutable());
}
#[Test]
public function reconstituteRestaureLEtat(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$yearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$entry1 = $this->createEntry(CalendarEntryType::HOLIDAY, '2024-11-01', '2024-11-01', 'Toussaint');
$entry2 = $this->createEntry(CalendarEntryType::VACATION, '2024-12-21', '2025-01-05', 'Noël');
$calendar = SchoolCalendar::reconstitute(
tenantId: $tenantId,
academicYearId: $yearId,
zone: SchoolZone::B,
entries: [$entry1, $entry2],
);
self::assertTrue($calendar->tenantId->equals($tenantId));
self::assertTrue($calendar->academicYearId->equals($yearId));
self::assertSame(SchoolZone::B, $calendar->zone);
self::assertCount(2, $calendar->entries());
self::assertEmpty($calendar->pullDomainEvents());
}
private function createCalendar(): SchoolCalendar
{
return SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
}
private function createEntry(
CalendarEntryType $type,
string $startDate,
string $endDate,
string $label,
): CalendarEntry {
return new CalendarEntry(
id: CalendarEntryId::generate(),
type: $type,
startDate: new DateTimeImmutable($startDate),
endDate: new DateTimeImmutable($endDate),
label: $label,
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SchoolZoneTest extends TestCase
{
#[Test]
public function zoneAContientLyonEtBordeaux(): void
{
$academies = SchoolZone::A->academies();
self::assertContains('Lyon', $academies);
self::assertContains('Bordeaux', $academies);
self::assertContains('Grenoble', $academies);
}
#[Test]
public function zoneBContientLilleEtNantes(): void
{
$academies = SchoolZone::B->academies();
self::assertContains('Lille', $academies);
self::assertContains('Nantes', $academies);
self::assertContains('Strasbourg', $academies);
}
#[Test]
public function zoneCContientParisEtToulouse(): void
{
$academies = SchoolZone::C->academies();
self::assertContains('Paris', $academies);
self::assertContains('Toulouse', $academies);
self::assertContains('Versailles', $academies);
}
#[Test]
public function backedValues(): void
{
self::assertSame('A', SchoolZone::A->value);
self::assertSame('B', SchoolZone::B->value);
self::assertSame('C', SchoolZone::C->value);
}
#[Test]
public function chaqueZoneADesAcademies(): void
{
foreach (SchoolZone::cases() as $zone) {
self::assertNotEmpty($zone->academies());
}
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\JourneePedagogiqueAjoutee;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
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\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Messaging\NotifyTeachersPedagogicalDayHandler;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use RuntimeException;
use Symfony\Component\Mailer\MailerInterface;
use Twig\Environment;
final class NotifyTeachersPedagogicalDayHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function itSendsEmailToAllTeachersInTenant(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$userRepository = $this->createMock(UserRepository::class);
$twig->method('render')->willReturn('<html>notification</html>');
$userRepository->method('findAllByTenant')->willReturn([
$this->createUser('teacher1@school.fr', [Role::PROF]),
$this->createUser('teacher2@school.fr', [Role::PROF]),
$this->createUser('parent@school.fr', [Role::PARENT]),
]);
$mailer->expects(self::exactly(2))->method('send');
$handler = new NotifyTeachersPedagogicalDayHandler($mailer, $twig, $userRepository, new NullLogger());
($handler)($this->createEvent());
}
#[Test]
public function itSkipsWhenNoTeachersInTenant(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$userRepository = $this->createMock(UserRepository::class);
$userRepository->method('findAllByTenant')->willReturn([
$this->createUser('parent@school.fr', [Role::PARENT]),
]);
$mailer->expects(self::never())->method('send');
$twig->expects(self::never())->method('render');
$handler = new NotifyTeachersPedagogicalDayHandler($mailer, $twig, $userRepository, new NullLogger());
($handler)($this->createEvent());
}
#[Test]
public function itHandlesMailerFailureGracefully(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$userRepository = $this->createMock(UserRepository::class);
$twig->method('render')->willReturn('<html>notification</html>');
$userRepository->method('findAllByTenant')->willReturn([
$this->createUser('teacher@school.fr', [Role::PROF]),
]);
$mailer->method('send')->willThrowException(new RuntimeException('SMTP error'));
$handler = new NotifyTeachersPedagogicalDayHandler($mailer, $twig, $userRepository, new NullLogger());
($handler)($this->createEvent());
$this->addToAssertionCount(1);
}
private function createEvent(): JourneePedagogiqueAjoutee
{
return new JourneePedagogiqueAjoutee(
entryId: CalendarEntryId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
date: new DateTimeImmutable('2025-03-14'),
label: 'Formation enseignants',
occurredOn: new DateTimeImmutable('2026-02-18 10:00:00'),
);
}
/**
* @param Role[] $roles
*/
private function createUser(string $email, array $roles): User
{
return User::reconstitute(
id: UserId::generate(),
email: new Email($email),
roles: $roles,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: StatutCompte::ACTIF,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-01'),
hashedPassword: 'hashed',
activatedAt: new DateTimeImmutable('2026-01-02'),
consentementParental: null,
);
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\CalendrierNonTrouveException;
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\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DoctrineSchoolCalendarRepositoryTest extends TestCase
{
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function saveDeletesExistingEntriesThenInsertsNew(): void
{
$connection = $this->createMock(Connection::class);
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::fromString('550e8400-e29b-41d4-a716-446655440020'),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-11-01'),
endDate: new DateTimeImmutable('2025-11-01'),
label: 'Toussaint',
));
$connection->expects(self::once())
->method('transactional')
->willReturnCallback(static function (callable $callback) { $callback(); });
// Expect: 1 DELETE + 1 INSERT
$connection->expects(self::exactly(2))
->method('executeStatement')
->with(
self::logicalOr(
self::stringContains('DELETE FROM school_calendar_entries'),
self::stringContains('INSERT INTO school_calendar_entries'),
),
self::isType('array'),
);
$repository = new DoctrineSchoolCalendarRepository($connection);
$repository->save($calendar);
}
#[Test]
public function saveWithMultipleEntriesExecutesMultipleInserts(): void
{
$connection = $this->createMock(Connection::class);
$calendar = SchoolCalendar::initialiser(
tenantId: TenantId::fromString(self::TENANT_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::fromString('550e8400-e29b-41d4-a716-446655440020'),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-11-01'),
endDate: new DateTimeImmutable('2025-11-01'),
label: 'Toussaint',
));
$calendar->ajouterEntree(new CalendarEntry(
id: CalendarEntryId::fromString('550e8400-e29b-41d4-a716-446655440021'),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-12-21'),
endDate: new DateTimeImmutable('2026-01-05'),
label: 'Noël',
));
$connection->expects(self::once())
->method('transactional')
->willReturnCallback(static function (callable $callback) { $callback(); });
// Expect: 1 DELETE + 2 INSERTs = 3 calls
$connection->expects(self::exactly(3))
->method('executeStatement');
$repository = new DoctrineSchoolCalendarRepository($connection);
$repository->save($calendar);
}
#[Test]
public function findByTenantAndYearReturnsNullWhenNoEntries(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')->willReturn([]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$calendar = $repository->findByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNull($calendar);
}
#[Test]
public function findByTenantAndYearReturnsCalendarWithEntries(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')
->willReturn([
$this->makeRow('550e8400-e29b-41d4-a716-446655440020', 'holiday', '2025-11-01', '2025-11-01', 'Toussaint', null, 'A'),
$this->makeRow('550e8400-e29b-41d4-a716-446655440021', 'vacation', '2025-12-21', '2026-01-05', 'Noël', 'Vacances de Noël', 'A'),
]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$calendar = $repository->findByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNotNull($calendar);
self::assertCount(2, $calendar->entries());
self::assertSame(SchoolZone::A, $calendar->zone);
self::assertSame('Toussaint', $calendar->entries()[0]->label);
self::assertSame('Noël', $calendar->entries()[1]->label);
}
#[Test]
public function findByTenantAndYearHandlesNullZone(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')
->willReturn([
$this->makeRow('550e8400-e29b-41d4-a716-446655440020', 'pedagogical', '2025-03-14', '2025-03-14', 'Formation', null, null),
]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$calendar = $repository->findByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertNotNull($calendar);
self::assertNull($calendar->zone);
}
#[Test]
public function getByTenantAndYearThrowsWhenNotFound(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')->willReturn([]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$this->expectException(CalendrierNonTrouveException::class);
$repository->getByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
}
#[Test]
public function getByTenantAndYearReturnsCalendarWhenFound(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('fetchAllAssociative')
->willReturn([
$this->makeRow('550e8400-e29b-41d4-a716-446655440020', 'holiday', '2025-05-01', '2025-05-01', 'Fête du travail', null, 'B'),
]);
$repository = new DoctrineSchoolCalendarRepository($connection);
$calendar = $repository->getByTenantAndYear(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
);
self::assertCount(1, $calendar->entries());
self::assertSame(SchoolZone::B, $calendar->zone);
}
/**
* @return array<string, mixed>
*/
private function makeRow(
string $id,
string $entryType,
string $startDate,
string $endDate,
string $label,
?string $description,
?string $zone,
): array {
return [
'id' => $id,
'tenant_id' => self::TENANT_ID,
'academic_year_id' => self::ACADEMIC_YEAR_ID,
'entry_type' => $entryType,
'start_date' => $startDate,
'end_date' => $endDate,
'label' => $label,
'description' => $description,
'zone' => $zone,
'created_at' => '2026-02-17T10:00:00+00:00',
];
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Security\CalendarVoter;
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 CalendarVoterTest extends TestCase
{
private CalendarVoter $voter;
protected function setUp(): void
{
$this->voter = new CalendarVoter();
}
#[Test]
public function itAbstainsForUnrelatedAttributes(): void
{
$token = $this->tokenWithRole(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, [CalendarVoter::VIEW]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
// --- VIEW ---
#[Test]
#[DataProvider('viewAllowedRolesProvider')]
public function itGrantsViewToStaffRoles(string $role): void
{
$token = $this->tokenWithRole($role);
$result = $this->voter->vote($token, null, [CalendarVoter::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 'PROF' => [Role::PROF->value];
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
}
#[Test]
#[DataProvider('viewDeniedRolesProvider')]
public function itDeniesViewToNonStaffRoles(string $role): void
{
$token = $this->tokenWithRole($role);
$result = $this->voter->vote($token, null, [CalendarVoter::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];
}
// --- CONFIGURE ---
#[Test]
#[DataProvider('configureAllowedRolesProvider')]
public function itGrantsConfigureToAdminRoles(string $role): void
{
$token = $this->tokenWithRole($role);
$result = $this->voter->vote($token, null, [CalendarVoter::CONFIGURE]);
self::assertSame(Voter::ACCESS_GRANTED, $result);
}
/**
* @return iterable<string, array{string}>
*/
public static function configureAllowedRolesProvider(): iterable
{
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
yield 'ADMIN' => [Role::ADMIN->value];
}
#[Test]
#[DataProvider('configureDeniedRolesProvider')]
public function itDeniesConfigureToNonAdminRoles(string $role): void
{
$token = $this->tokenWithRole($role);
$result = $this->voter->vote($token, null, [CalendarVoter::CONFIGURE]);
self::assertSame(Voter::ACCESS_DENIED, $result);
}
/**
* @return iterable<string, array{string}>
*/
public static function configureDeniedRolesProvider(): 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 tokenWithRole(string $role): TokenInterface
{
$user = $this->createMock(UserInterface::class);
$user->method('getRoles')->willReturn([$role]);
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($user);
return $token;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Service;
use App\Administration\Infrastructure\Service\FrenchPublicHolidaysCalculator;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class FrenchPublicHolidaysCalculatorTest extends TestCase
{
private FrenchPublicHolidaysCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new FrenchPublicHolidaysCalculator();
}
#[Test]
public function itReturns11HolidaysForAcademicYear(): void
{
$holidays = $this->calculator->pourAnneeScolaire('2024-2025');
self::assertCount(11, $holidays);
}
#[Test]
public function itReturnsFixedHolidaysFor20242025(): void
{
$holidays = $this->calculator->pourAnneeScolaire('2024-2025');
$dates = array_column($holidays, 'date');
self::assertContains('2024-11-01', $dates); // Toussaint
self::assertContains('2024-11-11', $dates); // Armistice
self::assertContains('2024-12-25', $dates); // Noël
self::assertContains('2025-01-01', $dates); // Jour de l'an
self::assertContains('2025-05-01', $dates); // Fête du travail
self::assertContains('2025-05-08', $dates); // Victoire 1945
self::assertContains('2025-07-14', $dates); // Fête nationale
self::assertContains('2025-08-15', $dates); // Assomption
}
#[Test]
public function itCalculatesEasterBasedHolidaysFor2025(): void
{
// Pâques 2025 = 20 avril 2025
$holidays = $this->calculator->pourAnneeScolaire('2024-2025');
$dates = array_column($holidays, 'date');
self::assertContains('2025-04-21', $dates); // Lundi de Pâques (20 avril + 1)
self::assertContains('2025-05-29', $dates); // Ascension (20 avril + 39)
self::assertContains('2025-06-09', $dates); // Pentecôte (20 avril + 50)
}
#[Test]
public function itCalculatesEasterBasedHolidaysFor2026(): void
{
// Pâques 2026 = 5 avril 2026
$holidays = $this->calculator->pourAnneeScolaire('2025-2026');
$dates = array_column($holidays, 'date');
self::assertContains('2026-04-06', $dates); // Lundi de Pâques (5 avril + 1)
self::assertContains('2026-05-14', $dates); // Ascension (5 avril + 39)
self::assertContains('2026-05-25', $dates); // Pentecôte (5 avril + 50)
}
#[Test]
public function holidaysAreSortedByDate(): void
{
$holidays = $this->calculator->pourAnneeScolaire('2024-2025');
$dates = array_column($holidays, 'date');
$sorted = $dates;
sort($sorted);
self::assertSame($sorted, $dates);
}
#[Test]
public function eachHolidayHasDateAndLabel(): void
{
$holidays = $this->calculator->pourAnneeScolaire('2025-2026');
foreach ($holidays as $holiday) {
self::assertArrayHasKey('date', $holiday);
self::assertArrayHasKey('label', $holiday);
self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $holiday['date']);
self::assertNotEmpty($holiday['label']);
}
}
}

View File

@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Service;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Infrastructure\Service\FrenchPublicHolidaysCalculator;
use App\Administration\Infrastructure\Service\JsonOfficialCalendarProvider;
use function count;
use InvalidArgumentException;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use RuntimeException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use function sys_get_temp_dir;
final class JsonOfficialCalendarProviderTest extends TestCase
{
private string $tempDir;
private JsonOfficialCalendarProvider $provider;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/classeo-calendar-test-' . uniqid();
$this->provider = new JsonOfficialCalendarProvider(
dataDirectory: $this->tempDir,
httpClient: new MockHttpClient($this->mockApiResponse()),
holidaysCalculator: new FrenchPublicHolidaysCalculator(),
logger: new NullLogger(),
);
}
protected function tearDown(): void
{
// Clean up temp files
$files = glob($this->tempDir . '/*');
if ($files !== false) {
foreach ($files as $file) {
unlink($file);
}
}
if (is_dir($this->tempDir)) {
rmdir($this->tempDir);
}
}
#[Test]
public function joursFeiesRetourneLesFeriesOfficiels(): void
{
$holidays = $this->provider->joursFeries('2024-2025');
self::assertNotEmpty($holidays);
foreach ($holidays as $holiday) {
self::assertSame(CalendarEntryType::HOLIDAY, $holiday->type);
}
}
#[Test]
public function joursFeiesContientToussaint(): void
{
$holidays = $this->provider->joursFeries('2024-2025');
$labels = array_map(static fn ($h) => $h->label, $holidays);
self::assertContains('Toussaint', $labels);
}
#[Test]
public function joursFeiesContientFeteNationale(): void
{
$holidays = $this->provider->joursFeries('2024-2025');
$labels = array_map(static fn ($h) => $h->label, $holidays);
self::assertContains('Fête nationale', $labels);
}
#[Test]
public function joursFeiesContientAssomption(): void
{
$holidays = $this->provider->joursFeries('2024-2025');
$labels = array_map(static fn ($h) => $h->label, $holidays);
self::assertContains('Assomption', $labels);
}
#[Test]
public function vacancesParZoneRetourneLesVacancesDeZoneA(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
self::assertNotEmpty($vacations);
foreach ($vacations as $vacation) {
self::assertSame(CalendarEntryType::VACATION, $vacation->type);
}
}
#[Test]
public function vacancesParZoneRetourneLesVacancesDeZoneB(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::B, '2024-2025');
self::assertNotEmpty($vacations);
}
#[Test]
public function vacancesParZoneRetourneLesVacancesDeZoneC(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::C, '2024-2025');
self::assertNotEmpty($vacations);
}
#[Test]
public function vacancesContiennentToussaintEtNoel(): void
{
$vacations = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
$labels = array_map(static fn ($v) => $v->label, $vacations);
self::assertContains('Vacances de la Toussaint', $labels);
self::assertContains('Vacances de Noël', $labels);
}
#[Test]
public function hiverDiffereSelonLaZone(): void
{
$vacationsA = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
$vacationsC = $this->provider->vacancesParZone(SchoolZone::C, '2024-2025');
$hiverA = null;
$hiverC = null;
foreach ($vacationsA as $v) {
if (str_contains($v->label, 'hiver')) {
$hiverA = $v;
}
}
foreach ($vacationsC as $v) {
if (str_contains($v->label, 'hiver')) {
$hiverC = $v;
}
}
self::assertNotNull($hiverA);
self::assertNotNull($hiverC);
self::assertNotSame(
$hiverA->startDate->format('Y-m-d'),
$hiverC->startDate->format('Y-m-d'),
);
}
#[Test]
public function toutesEntreesOfficiellesCombineJoursFeiesEtVacances(): void
{
$all = $this->provider->toutesEntreesOfficielles(SchoolZone::A, '2024-2025');
$holidays = $this->provider->joursFeries('2024-2025');
$vacations = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
self::assertCount(count($holidays) + count($vacations), $all);
}
#[Test]
public function anneeScolaireInconnueLeveException(): void
{
$provider = new JsonOfficialCalendarProvider(
dataDirectory: $this->tempDir,
httpClient: new MockHttpClient(new MockResponse('', ['http_code' => 500])),
holidaysCalculator: new FrenchPublicHolidaysCalculator(),
logger: new NullLogger(),
);
$this->expectException(RuntimeException::class);
$provider->joursFeries('2099-2100');
}
#[Test]
public function chaqueEntreeAUnIdUnique(): void
{
$all = $this->provider->toutesEntreesOfficielles(SchoolZone::A, '2024-2025');
$ids = array_map(static fn ($entry) => (string) $entry->id, $all);
$uniqueIds = array_unique($ids);
self::assertCount(count($all), $uniqueIds);
}
#[Test]
public function itRejectsPathTraversalInAcademicYear(): void
{
$this->expectException(InvalidArgumentException::class);
$this->provider->joursFeries('../../etc/passwd');
}
#[Test]
public function itRejectsODataInjectionInAcademicYear(): void
{
$this->expectException(InvalidArgumentException::class);
$this->provider->joursFeries('2024-2025"; DROP TABLE');
}
#[Test]
public function cacheFileIsCreatedOnFirstAccess(): void
{
$this->provider->joursFeries('2024-2025');
self::assertFileExists($this->tempDir . '/official-holidays-2024-2025.json');
}
#[Test]
public function apiStartDateIsAdjustedByOneDay(): void
{
// The gouv.fr API returns start_date as "la veille à 23h UTC" (e.g., 2024-10-18T23:00:00+00:00
// for a vacation starting 2024-10-19). The provider adds +1 day to start_date only.
// end_date represents the last vacation day directly (no shift).
$vacations = $this->provider->vacancesParZone(SchoolZone::A, '2024-2025');
$toussaint = null;
foreach ($vacations as $v) {
if (str_contains($v->label, 'Toussaint')) {
$toussaint = $v;
break;
}
}
self::assertNotNull($toussaint);
// Mock API has start_date '2024-10-18T23:00:00+00:00' → adjusted to 2024-10-19
self::assertSame('2024-10-19', $toussaint->startDate->format('Y-m-d'));
// Mock API has end_date '2024-11-03T23:00:00+00:00' → kept as 2024-11-03 (last vacation day)
self::assertSame('2024-11-03', $toussaint->endDate->format('Y-m-d'));
}
/**
* Creates a MockResponse simulating the gouv.fr API for 2024-2025 vacations.
*/
private function mockApiResponse(): MockResponse
{
$records = [
// Zone A
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-21T23:00:00+00:00', 'end_date' => '2025-03-09T23:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-18T22:00:00+00:00', 'end_date' => '2025-05-04T22:00:00+00:00', 'zones' => 'Zone A'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone A'],
// Zone B
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-07T23:00:00+00:00', 'end_date' => '2025-02-23T23:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-04T22:00:00+00:00', 'end_date' => '2025-04-20T22:00:00+00:00', 'zones' => 'Zone B'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone B'],
// Zone C
['description' => 'Vacances de la Toussaint', 'start_date' => '2024-10-18T23:00:00+00:00', 'end_date' => '2024-11-03T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances de Noël', 'start_date' => '2024-12-20T23:00:00+00:00', 'end_date' => '2025-01-05T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances d\'hiver', 'start_date' => '2025-02-14T23:00:00+00:00', 'end_date' => '2025-03-02T23:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances de printemps', 'start_date' => '2025-04-11T22:00:00+00:00', 'end_date' => '2025-04-27T22:00:00+00:00', 'zones' => 'Zone C'],
['description' => 'Vacances d\'été', 'start_date' => '2025-07-04T22:00:00+00:00', 'end_date' => '2025-08-31T22:00:00+00:00', 'zones' => 'Zone C'],
];
$body = json_encode(['results' => $records], JSON_THROW_ON_ERROR);
return new MockResponse($body, ['http_code' => 200]);
}
}