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 grade_events WHERE grade_id IN (SELECT id FROM grades 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 /me/grades — Auth & Access // ========================================================================= #[Test] public function getMyGradesReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function getMyGradesReturns403ForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } #[Test] public function getMyGradesReturns403ForParent(): void { $parentId = '88888888-8888-8888-8888-888888888888'; $client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } // ========================================================================= // GET /me/grades — Happy path // ========================================================================= #[Test] public function getMyGradesReturnsPublishedGradesForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var list> $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); // Only published grades should be returned (not unpublished) self::assertCount(2, $data); // First grade (sorted by eval date DESC, subject2 is more recent) self::assertSame(self::SUBJECT2_ID, $data[0]['subjectId']); self::assertSame(14.0, $data[0]['value']); self::assertSame('graded', $data[0]['status']); self::assertNotNull($data[0]['publishedAt']); // Second grade self::assertSame(self::SUBJECT_ID, $data[1]['subjectId']); self::assertSame(16.0, $data[1]['value']); } #[Test] public function getMyGradesDoesNotReturnUnpublishedGrades(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var list> $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); // The unpublished evaluation grade should not appear foreach ($data as $grade) { self::assertNotSame((string) $this->unpublishedEvalId, $grade['evaluationId']); } } #[Test] public function getMyGradesIncludesClassStatistics(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var list> $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); // First grade should have class statistics self::assertArrayHasKey('classAverage', $data[0]); self::assertArrayHasKey('classMin', $data[0]); self::assertArrayHasKey('classMax', $data[0]); } #[Test] public function getMyGradesReturnsEmptyForStudentWithNoGrades(): void { $noGradeStudentId = '77777777-7777-7777-7777-777777777777'; /** @var Connection $connection */ $connection = static::getContainer()->get(Connection::class); $connection->executeStatement( "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) VALUES (:id, :tid, 'no-grade@test.local', '', 'No', 'Grades', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => $noGradeStudentId, 'tid' => self::TENANT_ID], ); $client = $this->createAuthenticatedClient($noGradeStudentId, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var list $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertCount(0, $data); } // ========================================================================= // GET /me/grades/subject/{subjectId} — Happy path // ========================================================================= #[Test] public function getMyGradesBySubjectFiltersCorrectly(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/grades/subject/' . self::SUBJECT_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var list> $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertCount(1, $data); self::assertSame(self::SUBJECT_ID, $data[0]['subjectId']); self::assertSame(16.0, $data[0]['value']); } #[Test] public function getMyGradesBySubjectReturnsEmptyForUnknownSubject(): void { $unknownSubjectId = '99999999-9999-9999-9999-999999999999'; $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/grades/subject/' . $unknownSubjectId, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var list $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertCount(0, $data); } // ========================================================================= // GET /me/averages — Auth & Access // ========================================================================= #[Test] public function getMyAveragesReturns401WithoutAuthentication(): void { $client = static::createClient(); $client->request('GET', self::BASE_URL . '/me/averages', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(401); } #[Test] public function getMyAveragesReturns403ForTeacher(): void { $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); $client->request('GET', self::BASE_URL . '/me/averages', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseStatusCodeSame(403); } // ========================================================================= // GET /me/averages — Happy path // ========================================================================= #[Test] public function getMyAveragesReturnsAveragesForStudent(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); self::assertJsonContains([ 'studentId' => self::STUDENT_ID, 'periodId' => self::PERIOD_ID, 'generalAverage' => 16.0, ]); } #[Test] public function getMyAveragesReturnsSubjectAverages(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var array{subjectAverages: list>, generalAverage: float|null} $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); self::assertNotEmpty($data['subjectAverages']); self::assertSame(self::SUBJECT_ID, $data['subjectAverages'][0]['subjectId']); self::assertSame(16.0, $data['subjectAverages'][0]['average']); } // ========================================================================= // GET /me/grades — Student isolation // ========================================================================= #[Test] public function getMyGradesReturnsOnlyCurrentStudentGrades(): void { $client = $this->createAuthenticatedClient(self::STUDENT2_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var list> $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); // Student2 only has 1 grade (eval1, Maths), not the eval2/eval3 grades self::assertCount(1, $data); self::assertSame(12.0, $data[0]['value']); } // ========================================================================= // GET /me/grades — Response completeness // ========================================================================= #[Test] public function getMyGradesReturnsAllExpectedFields(): void { $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var list> $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); // First grade (eval2 — Français, more recent) $grade = $data[0]; self::assertArrayHasKey('id', $grade); self::assertArrayHasKey('evaluationId', $grade); self::assertArrayHasKey('evaluationTitle', $grade); self::assertArrayHasKey('evaluationDate', $grade); self::assertArrayHasKey('gradeScale', $grade); self::assertArrayHasKey('coefficient', $grade); self::assertArrayHasKey('subjectId', $grade); self::assertArrayHasKey('value', $grade); self::assertArrayHasKey('status', $grade); self::assertArrayHasKey('publishedAt', $grade); self::assertSame('Dictée', $grade['evaluationTitle']); self::assertSame(20, $grade['gradeScale']); self::assertSame(2.0, $grade['coefficient']); self::assertSame('Français', $grade['subjectName'] ?? null); } #[Test] public function getMyGradesIncludesAppreciationWhenSet(): void { // Add appreciation to eval1 grade /** @var Connection $connection */ $connection = static::getContainer()->get(Connection::class); $connection->executeStatement( "UPDATE grades SET appreciation = 'Excellent travail' WHERE student_id = :sid AND evaluation_id IN (SELECT id FROM evaluations WHERE title = 'DS Mathématiques' AND tenant_id = :tid)", ['sid' => self::STUDENT_ID, 'tid' => self::TENANT_ID], ); $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/grades', [ 'headers' => ['Accept' => 'application/json'], ]); self::assertResponseIsSuccessful(); /** @var string $content */ $content = $client->getResponse()->getContent(); /** @var list> $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); // Find the Maths grade (eval1) $mathsGrade = null; foreach ($data as $grade) { if (($grade['evaluationTitle'] ?? null) === 'DS Mathématiques') { $mathsGrade = $grade; break; } } self::assertNotNull($mathsGrade, 'DS Mathématiques grade not found'); self::assertSame('Excellent travail', $mathsGrade['appreciation']); } // ========================================================================= // GET /me/averages — Auto-detect period // ========================================================================= #[Test] public function getMyAveragesReturnsEmptyWhenNoPeriodCoversCurrentDate(): void { // The seeded period (2026-01-01 to 2026-03-31) does not cover today (2026-04-04) // So auto-detect returns no period → empty averages $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); $client->request('GET', self::BASE_URL . '/me/averages', [ '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('studentId', $data); self::assertEmpty($data['subjectAverages'] ?? []); } // ========================================================================= // 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 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-sg@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-sg@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, 'student2-sg@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 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-SG-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, 'Mathématiques', 'MATH', '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, 'Français', 'FRA', 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING", ['id' => self::SUBJECT2_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], ); /** @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); // Evaluation 1: Published, Subject 1 (Maths), older date $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 Mathématiques', description: null, evaluationDate: new DateTimeImmutable('2026-02-15'), gradeScale: new GradeScale(20), coefficient: new Coefficient(1.0), now: $now, ); $eval1->publierNotes($now); $eval1->pullDomainEvents(); $evalRepo->save($eval1); foreach ([ [self::STUDENT_ID, 16.0], [self::STUDENT2_ID, 12.0], ] as [$studentId, $value]) { $grade = Grade::saisir( tenantId: $tenantId, evaluationId: $eval1->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); } $stats1 = $calculator->calculateClassStatistics([16.0, 12.0]); $statsRepo->save($eval1->id, $stats1); // Evaluation 2: Published, Subject 2 (Français), more recent date $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', description: null, evaluationDate: new DateTimeImmutable('2026-03-01'), gradeScale: new GradeScale(20), coefficient: new Coefficient(2.0), now: $now, ); $eval2->publierNotes($now); $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); $stats2 = $calculator->calculateClassStatistics([14.0]); $statsRepo->save($eval2->id, $stats2); // Evaluation 3: NOT published (grades should NOT appear for student) $eval3 = Evaluation::creer( tenantId: $tenantId, classId: ClassId::fromString(self::CLASS_ID), subjectId: SubjectId::fromString(self::SUBJECT_ID), teacherId: UserId::fromString(self::TEACHER_ID), title: 'Contrôle surprise', description: null, evaluationDate: new DateTimeImmutable('2026-03-10'), gradeScale: new GradeScale(20), coefficient: new Coefficient(0.5), now: $now, ); // NOT published - don't call publierNotes() $eval3->pullDomainEvents(); $evalRepo->save($eval3); $this->unpublishedEvalId = $eval3->id; $grade3 = Grade::saisir( tenantId: $tenantId, evaluationId: $eval3->id, studentId: UserId::fromString(self::STUDENT_ID), value: new GradeValue(8.0), status: GradeStatus::GRADED, gradeScale: new GradeScale(20), createdBy: UserId::fromString(self::TEACHER_ID), now: $now, ); $grade3->pullDomainEvents(); $gradeRepo->save($grade3); // Save student averages for /me/averages endpoint /** @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, ); } }