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