Files
Classeo/backend/tests/Functional/Administration/Api/CalendarEndpointsTest.php
Mathias STRASSER e06fd5424d 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.
2026-02-18 12:09:19 +01:00

751 lines
25 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Functional\Administration\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
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\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
/**
* Tests for school calendar API endpoints.
*
* @see Story 2.11 - Configuration Calendrier Scolaire
*/
final class CalendarEndpointsTest extends ApiTestCase
{
protected static ?bool $alwaysBootKernel = true;
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string ACADEMIC_YEAR_ID = '11111111-1111-1111-1111-111111111111';
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440000';
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api/academic-years/11111111-1111-1111-1111-111111111111';
/** URL with symbolic 'current' ID — required for configure/import (year resolution). */
private const string CONFIGURE_URL = 'http://ecole-alpha.classeo.local/api/academic-years/current';
protected function tearDown(): void
{
$container = static::getContainer();
/** @var Connection $connection */
$connection = $container->get(Connection::class);
$connection->executeStatement(
'DELETE FROM school_calendar_entries WHERE tenant_id = :tenant_id',
['tenant_id' => self::TENANT_ID],
);
parent::tearDown();
}
// =========================================================================
// Security - Without tenant
// =========================================================================
#[Test]
public function getCalendarReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('GET', '/api/academic-years/current/calendar', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function configureCalendarReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('PUT', '/api/academic-years/current/calendar', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function importOfficialHolidaysReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('POST', '/api/academic-years/current/calendar/import-official', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function addPedagogicalDayReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('POST', '/api/academic-years/current/calendar/pedagogical-day', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['date' => '2025-03-14', 'label' => 'Formation', 'description' => 'Formation continue'],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function isSchoolDayReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('GET', '/api/academic-years/current/calendar/is-school-day/2025-03-14', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(404);
}
// =========================================================================
// Security - Without authentication (with tenant)
// =========================================================================
#[Test]
public function getCalendarReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar', [
'headers' => [
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function configureCalendarReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('PUT', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function importOfficialHolidaysReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('POST', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/import-official', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function addPedagogicalDayReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('POST', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['date' => '2025-03-14', 'label' => 'Formation', 'description' => 'Formation continue'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function isSchoolDayReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/is-school-day/2025-03-14', [
'headers' => [
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(401);
}
// =========================================================================
// Special identifiers - 'current'
// =========================================================================
#[Test]
public function getCalendarAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar', [
'headers' => ['Accept' => 'application/json'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function configureCalendarAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('PUT', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function importOfficialHolidaysAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('POST', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/import-official', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function addPedagogicalDayAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('POST', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['date' => '2025-03-14', 'label' => 'Formation', 'description' => 'Formation continue'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function isSchoolDayAcceptsCurrentIdentifier(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/calendar/is-school-day/2025-03-14', [
'headers' => ['Accept' => 'application/json'],
]);
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
self::assertResponseStatusCodeSame(401);
}
// =========================================================================
// AC3 (P0) - is-school-day with data
// =========================================================================
#[Test]
public function isSchoolDayReturnsFalseForHoliday(): void
{
$this->persistCalendar([
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-12-25'),
endDate: new DateTimeImmutable('2025-12-25'),
label: 'Noël',
),
]);
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-12-25', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertFalse($data['isSchoolDay']);
}
#[Test]
public function isSchoolDayReturnsFalseForVacationDay(): void
{
$this->persistCalendar([
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-02-15'),
endDate: new DateTimeImmutable('2025-03-02'),
label: 'Vacances d\'hiver',
),
]);
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-02-20', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertFalse($data['isSchoolDay']);
}
#[Test]
public function isSchoolDayReturnsFalseForPedagogicalDay(): void
{
$this->persistCalendar([
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::PEDAGOGICAL_DAY,
startDate: new DateTimeImmutable('2025-03-14'),
endDate: new DateTimeImmutable('2025-03-14'),
label: 'Formation continue',
),
]);
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-03-14', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertFalse($data['isSchoolDay']);
}
#[Test]
public function isSchoolDayReturnsTrueForNormalWeekday(): void
{
$this->persistCalendar();
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
// 2025-03-10 is a Monday
$response = $client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-03-10', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertTrue($data['isSchoolDay']);
}
#[Test]
public function isSchoolDayReturns403ForParent(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PARENT']);
$client->request('GET', self::BASE_URL . '/calendar/is-school-day/2025-03-10', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// AC1 - GET calendar with data
// =========================================================================
#[Test]
public function getCalendarReturnsDataForAdmin(): void
{
$this->persistCalendar(
[
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-12-25'),
endDate: new DateTimeImmutable('2025-12-25'),
label: 'Noël',
),
],
SchoolZone::A,
);
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('GET', self::BASE_URL . '/calendar', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('A', $data['zone']);
self::assertNotEmpty($data['entries']);
}
#[Test]
public function getCalendarReturns200ForProf(): void
{
$this->persistCalendar(
[
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-12-25'),
endDate: new DateTimeImmutable('2025-12-25'),
label: 'Noël',
),
],
SchoolZone::A,
);
$client = $this->createAuthenticatedClient(['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/calendar', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
}
#[Test]
public function getCalendarReturns403ForParent(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PARENT']);
$client->request('GET', self::BASE_URL . '/calendar', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// AC2 - Configure + Import
// =========================================================================
#[Test]
public function configureCalendarReturns200ForAdmin(): void
{
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('PUT', self::CONFIGURE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('A', $data['zone']);
}
#[Test]
public function configureCalendarReturns403ForProf(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PROF']);
$client->request('PUT', self::BASE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function importOfficialHolidaysReturns200ForAdmin(): void
{
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('POST', self::CONFIGURE_URL . '/calendar/import-official', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertNotEmpty($data['entries']);
}
#[Test]
public function importOfficialHolidaysReturns403ForProf(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PROF']);
$client->request('POST', self::BASE_URL . '/calendar/import-official', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['importZone' => 'A'],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// AC2 - Ajustement post-import
// =========================================================================
#[Test]
public function reconfigureCalendarChangesZoneAndEntries(): void
{
// Configure zone A
$clientA = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$responseA = $clientA->request('PUT', self::CONFIGURE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseIsSuccessful();
$dataA = $responseA->toArray();
self::assertSame('A', $dataA['zone']);
self::assertNotEmpty($dataA['entries']);
// Reconfigure zone B (new client — kernel reboots between requests)
$clientB = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$responseB = $clientB->request('PUT', self::CONFIGURE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'B'],
]);
self::assertResponseIsSuccessful();
$dataB = $responseB->toArray();
self::assertSame('B', $dataB['zone']);
self::assertNotEmpty($dataB['entries']);
}
#[Test]
public function addPedagogicalDayPreservesImportedEntries(): void
{
// Import zone A (uses 'current' — required for year resolution)
$clientImport = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$responseImport = $clientImport->request('PUT', self::CONFIGURE_URL . '/calendar', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => ['zone' => 'A'],
]);
self::assertResponseIsSuccessful();
$importedCount = count($responseImport->toArray()['entries']);
// Add pedagogical day on top (same 'current' year — new client, kernel reboots)
$clientAdd = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$responseAdd = $clientAdd->request('POST', self::CONFIGURE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => 'Formation continue',
'description' => 'Formation pédagogies actives',
],
]);
self::assertResponseIsSuccessful();
$dataAfterAdd = $responseAdd->toArray();
// Imported entries preserved + new pedagogical day added
self::assertCount($importedCount + 1, $dataAfterAdd['entries']);
$types = array_column($dataAfterAdd['entries'], 'type');
self::assertContains('pedagogical', $types);
}
// =========================================================================
// AC5 - Journée pédagogique
// =========================================================================
#[Test]
public function addPedagogicalDayReturns200ForAdmin(): void
{
$this->persistCalendar();
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$response = $client->request('POST', self::BASE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => 'Formation continue',
'description' => 'Formation sur les nouvelles pédagogies',
],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
$types = array_column($data['entries'], 'type');
self::assertContains('pedagogical', $types);
}
#[Test]
public function addPedagogicalDayReturns403ForProf(): void
{
$client = $this->createAuthenticatedClient(['ROLE_PROF']);
$client->request('POST', self::BASE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => 'Formation continue',
],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function addPedagogicalDayReturns403ForEleve(): void
{
$client = $this->createAuthenticatedClient(['ROLE_ELEVE']);
$client->request('POST', self::BASE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => 'Formation continue',
],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// Validation - Bad Request
// =========================================================================
#[Test]
public function addPedagogicalDayReturns400ForWhitespaceOnlyLabel(): void
{
$this->persistCalendar();
$client = $this->createAuthenticatedClient(['ROLE_ADMIN']);
$client->request('POST', self::BASE_URL . '/calendar/pedagogical-day', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'date' => '2025-03-14',
'label' => ' ',
'description' => 'Label is only whitespace',
],
]);
self::assertResponseStatusCodeSame(400);
}
// =========================================================================
// Helpers
// =========================================================================
private function createAuthenticatedClient(array $roles): \ApiPlatform\Symfony\Bundle\Test\Client
{
$client = static::createClient();
$user = new SecurityUser(
userId: UserId::fromString(self::USER_ID),
email: 'test@classeo.local',
hashedPassword: '',
tenantId: TenantId::fromString(self::TENANT_ID),
roles: $roles,
);
$client->loginUser($user, 'api');
return $client;
}
private function persistCalendar(array $entries = [], ?SchoolZone $zone = null): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
if ($zone !== null) {
$calendar->configurerZone($zone);
}
foreach ($entries as $entry) {
$calendar->ajouterEntree($entry);
}
/** @var SchoolCalendarRepository $repository */
$repository = static::getContainer()->get(SchoolCalendarRepository::class);
$repository->save($calendar);
}
}