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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user