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)', ['tid' => self::TENANT_ID]); $connection->executeStatement('DELETE FROM student_general_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); $connection->executeStatement('DELETE FROM student_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); $connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); $connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); parent::tearDown(); } // ========================================================================= // GET /evaluations/{id}/statistics — Auth // ========================================================================= #[Test] public function getEvaluationStatisticsReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function getEvaluationStatisticsReturns403ForNonOwner(): void { $client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } // ========================================================================= // GET /evaluations/{id}/statistics — Happy path // ========================================================================= #[Test] public function getEvaluationStatisticsReturnsStatsForOwner(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); self::assertJsonContains([ 'evaluationId' => (string) $this->evaluationId, 'gradedCount' => 2, ]); } #[Test] public function getEvaluationStatisticsReturns404ForUnknownEvaluation(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $unknownId = (string) EvaluationId::generate(); $client->request('GET', self::BASE_URL . '/evaluations/' . $unknownId . '/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(404); } // ========================================================================= // GET /students/{id}/averages — Auth // ========================================================================= #[Test] public function getStudentAveragesReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function getStudentAveragesReturns403ForUnrelatedParent(): void { $parentId = '88888888-8888-8888-8888-888888888888'; $client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } // ========================================================================= // GET /students/{id}/averages — Happy path // ========================================================================= #[Test] public function getStudentAveragesReturnsDataForStaff(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); self::assertJsonContains([ 'studentId' => self::STUDENT_ID, 'periodId' => self::PERIOD_ID, ]); } #[Test] public function getStudentAveragesReturnsOwnDataForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); self::assertJsonContains([ 'studentId' => self::STUDENT_ID, ]); } // ========================================================================= // GET /classes/{id}/statistics — Auth // ========================================================================= #[Test] public function getClassStatisticsReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function getClassStatisticsReturns403ForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function getClassStatisticsReturns403ForParent(): void { $parentId = '88888888-8888-8888-8888-888888888888'; $client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } // ========================================================================= // GET /classes/{id}/statistics — Happy path // ========================================================================= #[Test] public function getClassStatisticsReturnsDataForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); self::assertJsonContains([ 'classId' => self::CLASS_ID, ]); } #[Test] public function getClassStatisticsReturnsDataForAdmin(): void { $adminId = '99999999-9999-9999-9999-999999999999'; $client = $this->createAuthenticatedClient($adminId, ['ROLE_ADMIN']); $client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); } // ========================================================================= // 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@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 parent tables $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-moy@test.local', '', 'Test', 'Teacher', '[\"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-moy@test.local', '', 'Test', 'Student', '[\"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, 'student2-moy@test.local', '', 'Test', 'Student2', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => '33333333-3333-3333-3333-333333333333', 'tid' => self::TENANT_ID], ); $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-Moy-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, 'Test-Moy-Subject', 'TMOY', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], ); $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-03-31') ON CONFLICT (id) DO NOTHING", ['id' => self::PERIOD_ID, 'tid' => self::TENANT_ID, 'ayid' => $academicYearId], ); // Créer une évaluation publiée avec 2 notes $evaluation = Evaluation::creer( tenantId: $tenantId, classId: ClassId::fromString(self::CLASS_ID), subjectId: SubjectId::fromString(self::SUBJECT_ID), teacherId: UserId::fromString(self::TEACHER_ID), title: 'DS Mathématiques', description: null, evaluationDate: new DateTimeImmutable('2026-02-15'), gradeScale: new GradeScale(20), coefficient: new Coefficient(1.0), now: $now, ); $evaluation->publierNotes($now); $evaluation->pullDomainEvents(); /** @var EvaluationRepository $evalRepo */ $evalRepo = $container->get(EvaluationRepository::class); $evalRepo->save($evaluation); $this->evaluationId = $evaluation->id; /** @var GradeRepository $gradeRepo */ $gradeRepo = $container->get(GradeRepository::class); $student2Id = '33333333-3333-3333-3333-333333333333'; foreach ([ [self::STUDENT_ID, 16.0], [$student2Id, 12.0], ] as [$studentId, $value]) { $grade = Grade::saisir( tenantId: $tenantId, evaluationId: $evaluation->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); } // Calculer et sauvegarder les statistiques /** @var AverageCalculator $calculator */ $calculator = $container->get(AverageCalculator::class); $stats = $calculator->calculateClassStatistics([16.0, 12.0]); /** @var EvaluationStatisticsRepository $statsRepo */ $statsRepo = $container->get(EvaluationStatisticsRepository::class); $statsRepo->save($evaluation->id, $stats); // Sauvegarder une moyenne élève /** @var StudentAverageRepository $avgRepo */ $avgRepo = $container->get(StudentAverageRepository::class); $avgRepo->saveSubjectAverage( $tenantId, UserId::fromString(self::STUDENT_ID), SubjectId::fromString(self::SUBJECT_ID), self::PERIOD_ID, 16.0, 1, ); $avgRepo->saveGeneralAverage( $tenantId, UserId::fromString(self::STUDENT_ID), self::PERIOD_ID, 16.0, ); } }