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]); } }