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 teacher_id = :teach)', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]); $connection->executeStatement('DELETE FROM student_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); $connection->executeStatement('DELETE FROM student_general_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); $connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid AND created_by = :teach)', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]); $connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid AND created_by = :teach', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]); $connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid AND teacher_id = :teach', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]); $connection->executeStatement('DELETE FROM teacher_assignments WHERE tenant_id = :tid AND teacher_id = :teach', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]); $connection->executeStatement('DELETE FROM class_assignments WHERE tenant_id = :tid AND school_class_id = :class', ['tid' => self::TENANT_ID, 'class' => self::CLASS_ID]); $connection->executeStatement('DELETE FROM academic_periods WHERE id = :id', ['id' => self::PERIOD_ID]); $connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::TEACHER_ID]); $connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::STUDENT_ID]); $connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::STUDENT2_ID]); parent::tearDown(); } // ========================================================================= // GET /me/statistics — Auth & Access // ========================================================================= #[Test] public function overviewReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function overviewReturns403ForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function overviewReturns403ForParent(): void { $parentId = '99999999-9999-9999-9999-999999999999'; $client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/me/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } // ========================================================================= // GET /me/statistics — Happy path // ========================================================================= #[Test] public function overviewReturnsClassSummaryForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var array $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertArrayHasKey('teacherId', $data); self::assertSame(self::TEACHER_ID, $data['teacherId']); self::assertArrayHasKey('classes', $data); self::assertNotEmpty($data['classes']); $class = $data['classes'][0]; self::assertSame(self::CLASS_ID, $class['classId']); self::assertSame(self::SUBJECT_ID, $class['subjectId']); self::assertArrayHasKey('evaluationCount', $class); self::assertArrayHasKey('studentCount', $class); self::assertArrayHasKey('average', $class); self::assertArrayHasKey('successRate', $class); } // ========================================================================= // GET /me/statistics/classes/{classId} — Auth & Validation // ========================================================================= #[Test] public function classDetailReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function classDetailReturns403ForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function classDetailReturns400WithoutSubjectId(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(400); } #[Test] public function classDetailReturns400WithInvalidThreshold(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID . '&threshold=25', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(400); } // ========================================================================= // GET /me/statistics/classes/{classId} — Happy path // ========================================================================= #[Test] public function classDetailReturnsStatisticsForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var array $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertSame(self::CLASS_ID, $data['classId']); self::assertSame(self::SUBJECT_ID, $data['subjectId']); self::assertArrayHasKey('average', $data); self::assertArrayHasKey('successRate', $data); self::assertArrayHasKey('distribution', $data); self::assertCount(8, $data['distribution']); self::assertArrayHasKey('evolution', $data); self::assertArrayHasKey('students', $data); self::assertNotEmpty($data['students']); $student = $data['students'][0]; self::assertArrayHasKey('studentId', $student); self::assertArrayHasKey('studentName', $student); self::assertArrayHasKey('average', $student); self::assertArrayHasKey('inDifficulty', $student); self::assertArrayHasKey('trend', $student); } // ========================================================================= // GET /me/statistics/export — Auth & Validation // ========================================================================= #[Test] public function exportReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID . '&subjectId=' . self::SUBJECT_ID); self::assertResponseStatusCodeSame(401); } #[Test] public function exportReturns403ForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID . '&subjectId=' . self::SUBJECT_ID); self::assertResponseStatusCodeSame(403); } #[Test] public function exportReturns400WithoutClassId(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/statistics/export?subjectId=' . self::SUBJECT_ID); self::assertResponseStatusCodeSame(400); } #[Test] public function exportReturns400WithoutSubjectId(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID); self::assertResponseStatusCodeSame(400); } // ========================================================================= // GET /me/statistics/export — Happy path // ========================================================================= #[Test] public function exportReturnsCsvWithCorrectHeaders(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID . '&subjectId=' . self::SUBJECT_ID . '&className=6eB&subjectName=Math%C3%A9matiques'); self::assertResponseIsSuccessful(); self::assertResponseHeaderSame('content-type', 'text/csv; charset=UTF-8'); /** @var string $csv */ $csv = $client->getResponse()->getContent(); self::assertNotEmpty($csv); self::assertStringContainsString('Moyenne', $csv); } // ========================================================================= // GET /me/statistics/evaluations — Auth & Access // ========================================================================= #[Test] public function evaluationDifficultyReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/statistics/evaluations', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function evaluationDifficultyReturns403ForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/statistics/evaluations', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function evaluationDifficultyReturnsDataForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/statistics/evaluations', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var array $payload */ $payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertArrayHasKey('evaluations', $payload); /** @var list> $evaluations */ $evaluations = $payload['evaluations']; self::assertIsArray($evaluations); self::assertNotEmpty($evaluations); $eval = $evaluations[0]; self::assertArrayHasKey('evaluationId', $eval); self::assertArrayHasKey('title', $eval); self::assertArrayHasKey('gradedCount', $eval); } // ========================================================================= // GET /me/statistics/students/{studentId} — Auth & Validation // ========================================================================= #[Test] public function studentProgressionReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/statistics/students/' . self::STUDENT_ID . '?subjectId=' . self::SUBJECT_ID . '&classId=' . self::CLASS_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function studentProgressionReturns403ForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/statistics/students/' . self::STUDENT_ID . '?subjectId=' . self::SUBJECT_ID . '&classId=' . self::CLASS_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function studentProgressionReturnsDataForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/statistics/students/' . self::STUDENT_ID . '?subjectId=' . self::SUBJECT_ID . '&classId=' . self::CLASS_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var array $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertArrayHasKey('grades', $data); self::assertIsArray($data['grades']); } // ========================================================================= // 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-stats@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(); $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, 'teacher-stats@test.local', '', 'Marc', 'Dupont', '[\"ROLE_PROF\"]', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::TEACHER_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-stats1@test.local', '', 'Alice', 'Durand', '[\"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, 'student-stats2@test.local', '', 'Bob', 'Martin', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::STUDENT2_ID, 'tid' => self::TENANT_ID], ); // Seed class and subject $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, 'Stats-6eB', '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, 'Mathématiques', 'MATH', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], ); // Seed academic period (must cover current date for queries to return data) // Clean up any conflicting rows first (unique constraint on tenant_id, academic_year_id, sequence) $connection->executeStatement( 'DELETE FROM academic_periods WHERE tenant_id = :tid AND academic_year_id = :ayid AND sequence = 2', ['tid' => self::TENANT_ID, 'ayid' => $academicYearId], ); $connection->executeStatement( 'DELETE FROM academic_periods WHERE id = :id', ['id' => self::PERIOD_ID], ); $connection->executeStatement( "INSERT INTO academic_periods (id, tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date) VALUES (:id, :tid, :ayid, 'trimester', 2, 'Trimestre 2', '2026-01-01', '2026-06-30')", ['id' => self::PERIOD_ID, 'tid' => self::TENANT_ID, 'ayid' => $academicYearId], ); // Seed teacher assignment (required for statistics reader queries) $connection->executeStatement( "INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) VALUES (gen_random_uuid(), :tid, :teach, :class, :subj, :ayid, 'active', NOW(), NOW(), NOW()) ON CONFLICT DO NOTHING", ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID, 'class' => self::CLASS_ID, 'subj' => self::SUBJECT_ID, 'ayid' => $academicYearId], ); // Seed student class assignments (class_assignments links students to classes) $connection->executeStatement( 'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at) VALUES (gen_random_uuid(), :tid, :sid, :class, :ayid, NOW(), NOW()) ON CONFLICT DO NOTHING', ['tid' => self::TENANT_ID, 'sid' => self::STUDENT_ID, 'class' => self::CLASS_ID, 'ayid' => $academicYearId], ); $connection->executeStatement( 'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at) VALUES (gen_random_uuid(), :tid, :sid, :class, :ayid, NOW(), NOW()) ON CONFLICT DO NOTHING', ['tid' => self::TENANT_ID, 'sid' => self::STUDENT2_ID, 'class' => self::CLASS_ID, 'ayid' => $academicYearId], ); // Create and publish evaluations with grades /** @var EvaluationRepository $evalRepo */ $evalRepo = $container->get(EvaluationRepository::class); /** @var GradeRepository $gradeRepo */ $gradeRepo = $container->get(GradeRepository::class); $eval = 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 Stats', description: null, evaluationDate: new DateTimeImmutable('2026-03-15'), gradeScale: new GradeScale(20), coefficient: new Coefficient(1.0), now: $now, ); $eval->publierNotes($now); $eval->pullDomainEvents(); $evalRepo->save($eval); foreach ([ [self::STUDENT_ID, 15.0], [self::STUDENT2_ID, 8.0], ] as [$studentId, $value]) { $grade = Grade::saisir( tenantId: $tenantId, evaluationId: $eval->id, studentId: UserId::fromString($studentId), value: new GradeValue($value), status: GradeStatus::GRADED, gradeScale: new GradeScale(20), createdBy: UserId::fromString(self::TEACHER_ID), now: $now, ); $grade->pullDomainEvents(); $gradeRepo->save($grade); } } }