L'élève avait accès à ses compétences mais pas à ses notes numériques. Cette fonctionnalité lui donne une vue complète de sa progression scolaire avec moyennes par matière, détail par évaluation, statistiques de classe, et un mode "découverte" pour révéler ses notes à son rythme (FR14, FR15). Les notes ne sont visibles qu'après publication par l'enseignant, ce qui garantit que l'élève les découvre avant ses parents (délai 24h story 6.7).
462 lines
20 KiB
PHP
462 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Scolarite\Api;
|
|
|
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
|
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
|
use App\Scolarite\Domain\Repository\EvaluationRepository;
|
|
use App\Scolarite\Domain\Repository\EvaluationStatisticsRepository;
|
|
use App\Scolarite\Domain\Repository\GradeRepository;
|
|
use App\Scolarite\Domain\Service\AverageCalculator;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use Doctrine\DBAL\Connection;
|
|
|
|
use const JSON_THROW_ON_ERROR;
|
|
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
|
|
final class ParentGradeEndpointsTest extends ApiTestCase
|
|
{
|
|
protected static ?bool $alwaysBootKernel = true;
|
|
|
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
private const string PARENT_ID = '99990001-0001-0001-0001-000000000001';
|
|
private const string STUDENT_ID = '99990001-0001-0001-0001-000000000002';
|
|
private const string TEACHER_ID = '99990001-0001-0001-0001-000000000003';
|
|
private const string CLASS_ID = '99990001-0001-0001-0001-000000000010';
|
|
private const string SUBJECT_ID = '99990001-0001-0001-0001-000000000020';
|
|
private const string SUBJECT2_ID = '99990001-0001-0001-0001-000000000021';
|
|
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->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<array<string, mixed>>}} $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<array<string, mixed>>}} $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<array{childId: string, generalAverage: float|null, subjectAverages: list<mixed>}>} $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<mixed>} $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<string> $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);
|
|
}
|
|
}
|