Files
Classeo/backend/tests/Functional/Scolarite/Api/ParentGradeEndpointsTest.php
Mathias STRASSER dc2be898d5
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
2026-04-16 09:27:25 +02:00

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 getChildGradesReturns403ForUnlinkedChild(): 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(403);
}
// =========================================================================
// 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 getChildGradesBySubjectReturns403ForUnlinkedChild(): 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(403);
}
// =========================================================================
// 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);
}
}