seedFixtures(); } protected function tearDown(): void { /** @var Connection $connection */ $connection = static::getContainer()->get(Connection::class); $connection->executeStatement('DELETE FROM evaluation_statistics WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid AND class_id = :cid)', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]); $connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid AND evaluation_id IN (SELECT id FROM evaluations WHERE class_id = :cid))', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]); $connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid AND evaluation_id IN (SELECT id FROM evaluations WHERE class_id = :cid)', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]); $connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid AND class_id = :cid', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]); $connection->executeStatement('DELETE FROM student_guardians WHERE guardian_id = :gid', ['gid' => self::PARENT_ID]); $connection->executeStatement('DELETE FROM class_assignments WHERE user_id = :uid', ['uid' => self::STUDENT_ID]); $connection->executeStatement('DELETE FROM users WHERE id IN (:p, :s, :t)', ['p' => self::PARENT_ID, 's' => self::STUDENT_ID, 't' => self::TEACHER_ID]); parent::tearDown(); } // ========================================================================= // GET /api/me/children/{childId}/grades — Auth & Access // ========================================================================= #[Test] public function getChildGradesReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function getChildGradesReturns403ForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function getChildGradesReturns403ForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function getChildGradesReturns404ForUnlinkedChild(): void { $unlinkedChildId = '99990001-0001-0001-0001-000000000099'; $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/me/children/' . $unlinkedChildId . '/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(404); } // ========================================================================= // GET /api/me/children/{childId}/grades — Happy path // ========================================================================= #[Test] public function getChildGradesReturnsGradesForLinkedChild(): void { $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var array{data: array{childId: string, grades: list>}} $json */ $json = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertSame(self::STUDENT_ID, $json['data']['childId']); self::assertNotEmpty($json['data']['grades']); $grade = $json['data']['grades'][0]; self::assertArrayHasKey('evaluationTitle', $grade); self::assertArrayHasKey('value', $grade); self::assertArrayHasKey('status', $grade); self::assertArrayHasKey('classAverage', $grade); } // ========================================================================= // GET /api/me/children/{childId}/grades/subject/{subjectId} — Auth & Access // ========================================================================= #[Test] public function getChildGradesBySubjectReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function getChildGradesBySubjectReturns403ForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function getChildGradesBySubjectReturns403ForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function getChildGradesBySubjectReturns404ForUnlinkedChild(): void { $unlinkedChildId = '99990001-0001-0001-0001-000000000099'; $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/me/children/' . $unlinkedChildId . '/grades/subject/' . self::SUBJECT_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(404); } // ========================================================================= // GET /api/me/children/{childId}/grades/subject/{subjectId} — Happy path // ========================================================================= #[Test] public function getChildGradesBySubjectFiltersCorrectly(): void { $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var array{data: array{grades: list>}} $json */ $json = json_decode($content, true, 512, JSON_THROW_ON_ERROR); foreach ($json['data']['grades'] as $grade) { self::assertSame(self::SUBJECT_ID, $grade['subjectId']); } } // ========================================================================= // GET /api/me/children/grades/summary — Auth & Access // ========================================================================= #[Test] public function getGradesSummaryReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/children/grades/summary', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function getGradesSummaryReturns403ForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/children/grades/summary', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } // ========================================================================= // GET /api/me/children/grades/summary — Happy path // ========================================================================= #[Test] public function getGradesSummaryReturnsAveragesForParent(): void { $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/me/children/grades/summary', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var array{data: list}>} $json */ $json = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertNotEmpty($json['data']); self::assertSame(self::STUDENT_ID, $json['data'][0]['childId']); self::assertNotNull($json['data'][0]['generalAverage']); } #[Test] public function getGradesSummaryAcceptsPeriodIdQueryParameter(): void { $periodId = '99990001-0001-0001-0001-000000000050'; $client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/me/children/grades/summary?periodId=' . $periodId, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var array{data: list} $json */ $json = json_decode($content, true, 512, JSON_THROW_ON_ERROR); // With a non-existent period, the response should still be 200 but with // empty or zero averages (no grades match). The key assertion is that the // endpoint accepts the parameter without error. self::assertIsArray($json['data']); } #[Test] public function getGradesSummaryReturns403ForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/children/grades/summary', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } // ========================================================================= // Helpers // ========================================================================= /** * @param list $roles */ private function createAuthenticatedClient(string $userId, array $roles): \ApiPlatform\Symfony\Bundle\Test\Client { $client = static::createClient(); $user = new SecurityUser( userId: UserId::fromString($userId), email: 'test-pg@classeo.local', hashedPassword: '', tenantId: TenantId::fromString(self::TENANT_ID), roles: $roles, ); $client->loginUser($user, 'api'); return $client; } private function seedFixtures(): void { $container = static::getContainer(); /** @var Connection $connection */ $connection = $container->get(Connection::class); $tenantId = TenantId::fromString(self::TENANT_ID); $now = new DateTimeImmutable('2026-03-15 10:00:00'); $schoolId = '550e8400-e29b-41d4-a716-ff6655440001'; $academicYearId = '550e8400-e29b-41d4-a716-ff6655440002'; // Seed users $connection->executeStatement( "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) VALUES (:id, :tid, 'parent-pg@test.local', '', 'Marie', 'Dupont', '[\"ROLE_PARENT\"]', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::PARENT_ID, 'tid' => self::TENANT_ID], ); $connection->executeStatement( "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) VALUES (:id, :tid, 'student-pg@test.local', '', 'Emma', 'Dupont', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::STUDENT_ID, 'tid' => self::TENANT_ID], ); $connection->executeStatement( "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) VALUES (:id, :tid, 'teacher-pg@test.local', '', 'Jean', 'Martin', '[\"ROLE_PROF\"]', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID], ); // Link parent to student $connection->executeStatement( "INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at) VALUES (gen_random_uuid(), :tid, :sid, :gid, 'mère', NOW()) ON CONFLICT DO NOTHING", ['tid' => self::TENANT_ID, 'sid' => self::STUDENT_ID, 'gid' => self::PARENT_ID], ); // Seed class and subjects $connection->executeStatement( "INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at) VALUES (:id, :tid, :sid, :ayid, 'Test-PG-Class', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId], ); $connection->executeStatement( "INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) VALUES (:id, :tid, :sid, 'PG-Mathématiques', 'PGMATH', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], ); $connection->executeStatement( "INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) VALUES (:id, :tid, :sid, 'PG-Français', 'PGFRA', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::SUBJECT2_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], ); // Assign student to class $connection->executeStatement( 'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) VALUES (gen_random_uuid(), :tid, :uid, :cid, :ayid, NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING', ['tid' => self::TENANT_ID, 'uid' => self::STUDENT_ID, 'cid' => self::CLASS_ID, 'ayid' => $academicYearId], ); /** @var EvaluationRepository $evalRepo */ $evalRepo = $container->get(EvaluationRepository::class); /** @var GradeRepository $gradeRepo */ $gradeRepo = $container->get(GradeRepository::class); /** @var AverageCalculator $calculator */ $calculator = $container->get(AverageCalculator::class); /** @var EvaluationStatisticsRepository $statsRepo */ $statsRepo = $container->get(EvaluationStatisticsRepository::class); // Published evaluation (well past 24h delay) $eval1 = Evaluation::creer( tenantId: $tenantId, classId: ClassId::fromString(self::CLASS_ID), subjectId: SubjectId::fromString(self::SUBJECT_ID), teacherId: UserId::fromString(self::TEACHER_ID), title: 'DS Maths PG', description: null, evaluationDate: new DateTimeImmutable('2026-02-15'), gradeScale: new GradeScale(20), coefficient: new Coefficient(1.0), now: new DateTimeImmutable('2026-02-10'), ); $eval1->publierNotes(new DateTimeImmutable('2026-02-16 10:00:00')); $eval1->pullDomainEvents(); $evalRepo->save($eval1); $grade1 = Grade::saisir( tenantId: $tenantId, evaluationId: $eval1->id, studentId: UserId::fromString(self::STUDENT_ID), value: new GradeValue(15.0), status: GradeStatus::GRADED, gradeScale: new GradeScale(20), createdBy: UserId::fromString(self::TEACHER_ID), now: $now, ); $grade1->pullDomainEvents(); $gradeRepo->save($grade1); $stats1 = $calculator->calculateClassStatistics([15.0, 12.0, 18.0]); $statsRepo->save($eval1->id, $stats1); // Second evaluation, different subject $eval2 = Evaluation::creer( tenantId: $tenantId, classId: ClassId::fromString(self::CLASS_ID), subjectId: SubjectId::fromString(self::SUBJECT2_ID), teacherId: UserId::fromString(self::TEACHER_ID), title: 'Dictée PG', description: null, evaluationDate: new DateTimeImmutable('2026-03-01'), gradeScale: new GradeScale(20), coefficient: new Coefficient(2.0), now: new DateTimeImmutable('2026-02-25'), ); $eval2->publierNotes(new DateTimeImmutable('2026-03-02 10:00:00')); $eval2->pullDomainEvents(); $evalRepo->save($eval2); $grade2 = Grade::saisir( tenantId: $tenantId, evaluationId: $eval2->id, studentId: UserId::fromString(self::STUDENT_ID), value: new GradeValue(14.0), status: GradeStatus::GRADED, gradeScale: new GradeScale(20), createdBy: UserId::fromString(self::TEACHER_ID), now: $now, ); $grade2->pullDomainEvents(); $gradeRepo->save($grade2); } }