Files
Classeo/backend/tests/Functional/Scolarite/Api/CompetencyEndpointsTest.php
Mathias STRASSER b7dc27f2a5
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: Calculer automatiquement les moyennes après chaque saisie de notes
Les enseignants ont besoin de moyennes à jour immédiatement après la
publication ou modification des notes, sans attendre un batch nocturne.

Le système recalcule via Domain Events synchrones : statistiques
d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées
(normalisation /20), et moyenne générale par élève. Les résultats sont
stockés dans des tables dénormalisées avec cache Redis (TTL 5 min).

Trois endpoints API exposent les données avec contrôle d'accès par rôle.
Une commande console permet le backfill des données historiques au
déploiement.
2026-04-04 02:25:00 +02:00

895 lines
38 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Functional\Scolarite\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
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\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use const JSON_THROW_ON_ERROR;
use PHPUnit\Framework\Attributes\Test;
use Ramsey\Uuid\Uuid;
/**
* Functional tests for competency mode endpoints (Story 6.5).
*
* Covers: competencies listing, competency levels, evaluation-competency linking,
* competency result grids, saving results, and student progress tracking.
*/
final class CompetencyEndpointsTest extends ApiTestCase
{
protected static ?bool $alwaysBootKernel = true;
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string TEACHER_ID = 'cc444444-4444-4444-4444-444444444444';
private const string OTHER_TEACHER_ID = 'cc444444-4444-4444-4444-444444444445';
private const string STUDENT_ID = 'cc222222-2222-2222-2222-222222222222';
private const string STUDENT2_ID = 'cc222222-2222-2222-2222-222222222223';
private const string CLASS_ID = 'cc555555-5555-5555-5555-555555555555';
private const string SUBJECT_ID = 'cc666666-6666-6666-6666-666666666666';
private const string FRAMEWORK_ID = 'cc777777-7777-7777-7777-777777777777';
private const string COMPETENCY1_ID = 'cc888888-8888-8888-8888-888888888881';
private const string COMPETENCY2_ID = 'cc888888-8888-8888-8888-888888888882';
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
private ?EvaluationId $evaluationId = null;
private ?string $competencyEvaluation1Id = null;
private ?string $competencyEvaluation2Id = null;
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 student_competency_results WHERE competency_evaluation_id IN (SELECT id FROM competency_evaluations WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid))',
['tid' => self::TENANT_ID],
);
$connection->executeStatement(
'DELETE FROM competency_evaluations WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid)',
['tid' => self::TENANT_ID],
);
$connection->executeStatement(
'DELETE FROM competencies WHERE framework_id IN (SELECT id FROM competency_frameworks WHERE tenant_id = :tid)',
['tid' => self::TENANT_ID],
);
$connection->executeStatement(
'DELETE FROM competency_frameworks WHERE tenant_id = :tid',
['tid' => self::TENANT_ID],
);
$connection->executeStatement(
'DELETE FROM evaluations WHERE tenant_id = :tid',
['tid' => self::TENANT_ID],
);
parent::tearDown();
}
// =========================================================================
// GET /competencies — Auth & Happy path
// =========================================================================
#[Test]
public function getCompetenciesReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', self::BASE_URL . '/competencies', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getCompetenciesReturns200ForAuthenticatedUser(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/competencies', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $client->getResponse()->getContent();
/** @var array<array{id: string, code: string, name: string}> $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
self::assertCount(2, $data);
self::assertSame(self::COMPETENCY1_ID, $data[0]['id']);
self::assertSame('C1', $data[0]['code']);
self::assertSame('Lire et comprendre', $data[0]['name']);
self::assertSame(self::COMPETENCY2_ID, $data[1]['id']);
self::assertSame('C2', $data[1]['code']);
self::assertSame('Ecrire', $data[1]['name']);
}
// =========================================================================
// GET /competency-levels — Auth & Happy path
// =========================================================================
#[Test]
public function getCompetencyLevelsReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', self::BASE_URL . '/competency-levels', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getCompetencyLevelsReturns200WithStandard4Levels(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/competency-levels', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $client->getResponse()->getContent();
/** @var array<array{code: string, name: string}> $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
self::assertCount(4, $data);
self::assertSame('not_acquired', $data[0]['code']);
self::assertSame('Non acquis', $data[0]['name']);
self::assertSame('in_progress', $data[1]['code']);
self::assertSame('acquired', $data[2]['code']);
self::assertSame('exceeded', $data[3]['code']);
}
// =========================================================================
// GET /evaluations/{id}/competencies — Auth & edge cases
// =========================================================================
#[Test]
public function getEvaluationCompetenciesReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competencies', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getEvaluationCompetenciesReturns403ForNonOwner(): void
{
$client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competencies', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function getEvaluationCompetenciesReturns404ForUnknownEvaluation(): void
{
$unknownId = (string) EvaluationId::generate();
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/evaluations/' . $unknownId . '/competencies', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(404);
}
#[Test]
public function getEvaluationCompetenciesReturns200ForOwner(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competencies', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $client->getResponse()->getContent();
/** @var array<array{id: string, competencyId: string, competencyCode: string}> $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
self::assertCount(2, $data);
self::assertSame((string) $this->evaluationId, $data[0]['evaluationId']);
self::assertSame(self::COMPETENCY1_ID, $data[0]['competencyId']);
self::assertSame('C1', $data[0]['competencyCode']);
self::assertSame(self::COMPETENCY2_ID, $data[1]['competencyId']);
self::assertSame('C2', $data[1]['competencyCode']);
}
// =========================================================================
// GET /evaluations/{id}/competency-results — Auth & Happy path
// =========================================================================
#[Test]
public function getCompetencyResultsReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getCompetencyResultsReturns403ForNonOwner(): void
{
$client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function getCompetencyResultsReturns200ForOwnerWithGrid(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $client->getResponse()->getContent();
/** @var array<array{studentId: string, competencyEvaluationId: string, levelCode: string|null}> $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
// 2 students x 2 competencies = 4 rows in the result matrix
self::assertCount(4, $data);
// Find the seeded result (student1 + competency1 = acquired)
$seededResult = null;
foreach ($data as $row) {
if ($row['studentId'] === self::STUDENT_ID && $row['competencyEvaluationId'] === $this->competencyEvaluation1Id) {
$seededResult = $row;
break;
}
}
self::assertNotNull($seededResult);
self::assertSame('acquired', $seededResult['levelCode']);
}
// =========================================================================
// PUT /evaluations/{id}/competency-results — Auth & Happy path
// =========================================================================
#[Test]
public function putCompetencyResultsReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('PUT', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'results' => [],
],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function putCompetencyResultsReturns403ForNonOwner(): void
{
$client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']);
$client->request('PUT', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'results' => [],
],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function putCompetencyResultsReturns200AndSavesResults(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('PUT', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'results' => [
[
'studentId' => self::STUDENT2_ID,
'competencyEvaluationId' => $this->competencyEvaluation1Id,
'levelCode' => 'in_progress',
],
[
'studentId' => self::STUDENT2_ID,
'competencyEvaluationId' => $this->competencyEvaluation2Id,
'levelCode' => 'not_acquired',
],
],
],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $client->getResponse()->getContent();
/** @var array<array{studentId: string, levelCode: string}> $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
self::assertCount(2, $data);
self::assertSame(self::STUDENT2_ID, $data[0]['studentId']);
self::assertSame('in_progress', $data[0]['levelCode']);
self::assertSame(self::STUDENT2_ID, $data[1]['studentId']);
self::assertSame('not_acquired', $data[1]['levelCode']);
}
// =========================================================================
// POST /evaluations/{id}/competencies — Auth & Happy path
// =========================================================================
#[Test]
public function postLinkCompetenciesReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('POST', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competencies', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'competencyIds' => [self::COMPETENCY1_ID],
],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function postLinkCompetenciesReturns403ForNonOwner(): void
{
$client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']);
$client->request('POST', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competencies', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'competencyIds' => [self::COMPETENCY1_ID],
],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function postLinkCompetenciesReturns200AndLinksCompetencies(): void
{
// First, remove existing links to test fresh linking
/** @var Connection $connection */
$connection = static::getContainer()->get(Connection::class);
$connection->executeStatement(
'DELETE FROM student_competency_results WHERE competency_evaluation_id IN (SELECT id FROM competency_evaluations WHERE evaluation_id = :eid)',
['eid' => (string) $this->evaluationId],
);
$connection->executeStatement(
'DELETE FROM competency_evaluations WHERE evaluation_id = :eid',
['eid' => (string) $this->evaluationId],
);
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('POST', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competencies', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'competencyIds' => [self::COMPETENCY1_ID, self::COMPETENCY2_ID],
],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $client->getResponse()->getContent();
/** @var array<array{evaluationId: string, competencyId: string}> $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
self::assertCount(2, $data);
self::assertSame((string) $this->evaluationId, $data[0]['evaluationId']);
self::assertSame(self::COMPETENCY1_ID, $data[0]['competencyId']);
self::assertSame(self::COMPETENCY2_ID, $data[1]['competencyId']);
}
// =========================================================================
// PUT /evaluations/{id}/competency-results — Validation (error paths)
// =========================================================================
#[Test]
public function putCompetencyResultsReturns400ForInvalidLevelCode(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('PUT', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'results' => [
[
'studentId' => self::STUDENT_ID,
'competencyEvaluationId' => $this->competencyEvaluation1Id,
'levelCode' => 'INVALID_CODE',
],
],
],
]);
self::assertResponseStatusCodeSame(400);
}
#[Test]
public function putCompetencyResultsReturns400ForStudentNotInClass(): void
{
$outsiderId = 'cc999999-9999-9999-9999-999999999999';
/** @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, 'outsider-comp@test.local', '', 'Outsider', 'User', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => $outsiderId, 'tid' => self::TENANT_ID],
);
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('PUT', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'results' => [
[
'studentId' => $outsiderId,
'competencyEvaluationId' => $this->competencyEvaluation1Id,
'levelCode' => 'acquired',
],
],
],
]);
self::assertResponseStatusCodeSame(400);
}
#[Test]
public function putCompetencyResultsReturns400ForInvalidCompetencyEvaluationId(): void
{
$fakeCeId = (string) Uuid::uuid4();
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('PUT', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'results' => [
[
'studentId' => self::STUDENT_ID,
'competencyEvaluationId' => $fakeCeId,
'levelCode' => 'acquired',
],
],
],
]);
self::assertResponseStatusCodeSame(400);
}
#[Test]
public function putCompetencyResultsDeletesResultWhenLevelCodeIsNull(): void
{
// Verify the seeded result exists via the results grid
$getClient = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$getClient->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $getClient->getResponse()->getContent();
/** @var array<array{studentId: string, competencyEvaluationId: string, levelCode: string|null}> $before */
$before = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
$seededBefore = null;
foreach ($before as $row) {
if ($row['studentId'] === self::STUDENT_ID && $row['competencyEvaluationId'] === $this->competencyEvaluation1Id) {
$seededBefore = $row;
break;
}
}
self::assertNotNull($seededBefore);
self::assertSame('acquired', $seededBefore['levelCode']);
// Send null levelCode to delete the result
$putClient = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$putClient->request('PUT', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'results' => [
[
'studentId' => self::STUDENT_ID,
'competencyEvaluationId' => $this->competencyEvaluation1Id,
'levelCode' => null,
],
],
],
]);
self::assertResponseIsSuccessful();
// Verify the result is now null in the grid
$verifyClient = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$verifyClient->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Accept' => 'application/json'],
]);
/** @var string $afterContent */
$afterContent = $verifyClient->getResponse()->getContent();
/** @var array<array{studentId: string, competencyEvaluationId: string, levelCode: string|null}> $after */
$after = json_decode($afterContent, true, 512, JSON_THROW_ON_ERROR);
$seededAfter = null;
foreach ($after as $row) {
if ($row['studentId'] === self::STUDENT_ID && $row['competencyEvaluationId'] === $this->competencyEvaluation1Id) {
$seededAfter = $row;
break;
}
}
self::assertNotNull($seededAfter);
self::assertNull($seededAfter['levelCode'] ?? null);
}
// =========================================================================
// POST /evaluations/{id}/competencies — Validation (error paths)
// =========================================================================
#[Test]
public function postLinkCompetenciesReturns400ForUnknownCompetencyId(): void
{
$fakeCompetencyId = (string) Uuid::uuid4();
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('POST', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competencies', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'competencyIds' => [$fakeCompetencyId],
],
]);
self::assertResponseStatusCodeSame(400);
}
#[Test]
public function postLinkCompetenciesIsIdempotentForAlreadyLinkedCompetency(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
// Link the same competencies that are already linked
$client->request('POST', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competencies', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'competencyIds' => [self::COMPETENCY1_ID],
],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $client->getResponse()->getContent();
/** @var array<array{competencyId: string}> $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
self::assertCount(1, $data);
self::assertSame(self::COMPETENCY1_ID, $data[0]['competencyId']);
}
// =========================================================================
// AC6: Mode mixte — Competency results don't create grades
// =========================================================================
#[Test]
public function competencyResultsDoNotCreateGradeEntries(): void
{
/** @var Connection $connection */
$connection = static::getContainer()->get(Connection::class);
// Count grades before saving competency results
/** @var string $countBefore */
$countBefore = $connection->fetchOne(
'SELECT COUNT(*) FROM grades WHERE evaluation_id = :eid',
['eid' => (string) $this->evaluationId],
);
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('PUT', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/competency-results', [
'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'],
'json' => [
'results' => [
[
'studentId' => self::STUDENT_ID,
'competencyEvaluationId' => $this->competencyEvaluation1Id,
'levelCode' => 'exceeded',
],
[
'studentId' => self::STUDENT2_ID,
'competencyEvaluationId' => $this->competencyEvaluation2Id,
'levelCode' => 'in_progress',
],
],
],
]);
self::assertResponseIsSuccessful();
// Count grades after — should be unchanged (competency results don't create grades)
/** @var string $countAfter */
$countAfter = $connection->fetchOne(
'SELECT COUNT(*) FROM grades WHERE evaluation_id = :eid',
['eid' => (string) $this->evaluationId],
);
self::assertSame($countBefore, $countAfter, 'Saving competency results must not create grade entries');
// Also verify no student_averages were created for this evaluation
/** @var string $avgCount */
$avgCount = $connection->fetchOne(
'SELECT COUNT(*) FROM student_averages WHERE tenant_id = :tid AND subject_id = :sid',
['tid' => self::TENANT_ID, 'sid' => self::SUBJECT_ID],
);
self::assertSame(0, (int) $avgCount, 'Competency results must not trigger average calculation');
}
// =========================================================================
// GET /students/{id}/competency-progress — Auth & Happy path
// =========================================================================
#[Test]
public function getStudentCompetencyProgressReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/competency-progress', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getStudentCompetencyProgressReturns403ForOtherStudent(): void
{
$client = $this->createAuthenticatedClient(self::STUDENT2_ID, ['ROLE_ELEVE']);
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/competency-progress', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function getStudentCompetencyProgressReturns200ForOwnData(): void
{
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/competency-progress', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $client->getResponse()->getContent();
/** @var array<array{competencyId: string, currentLevelCode: string}> $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
self::assertCount(1, $data);
self::assertSame(self::COMPETENCY1_ID, $data[0]['competencyId']);
self::assertSame('acquired', $data[0]['currentLevelCode']);
self::assertSame('Acquis', $data[0]['currentLevelName']);
self::assertNotEmpty($data[0]['history']);
}
#[Test]
public function getStudentCompetencyProgressReturns200ForTeacher(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/competency-progress', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
/** @var string $content */
$content = $client->getResponse()->getContent();
/** @var array<array{competencyId: string}> $data */
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
self::assertCount(1, $data);
self::assertSame(self::COMPETENCY1_ID, $data[0]['competencyId']);
}
// =========================================================================
// Helpers
// =========================================================================
/**
* @param list<string> $roles
*/
private function createAuthenticatedClient(string $userId, array $roles): Client
{
$client = static::createClient();
$user = new SecurityUser(
userId: UserId::fromString($userId),
email: 'test-comp@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';
// --- 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-comp@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, 'other-teacher-comp@test.local', '', 'Other', 'Teacher', '[\"ROLE_PROF\"]', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => self::OTHER_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, 'student1-comp@test.local', '', 'Alice', '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, 'student2-comp@test.local', '', 'Bob', 'Martin', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => self::STUDENT2_ID, 'tid' => self::TENANT_ID],
);
// --- School class ---
$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-Comp-Class', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId],
);
// --- Subject ---
$connection->executeStatement(
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
VALUES (:id, :tid, :sid, 'Test-Comp-Subject', 'TCOMP', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
);
// --- Class assignments (students assigned to class) ---
$classAssignment1Id = (string) Uuid::uuid4();
$connection->executeStatement(
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at)
VALUES (:id, :tid, :uid, :cid, :ayid, NOW(), NOW(), NOW())
ON CONFLICT (user_id, academic_year_id) DO NOTHING',
['id' => $classAssignment1Id, 'tid' => self::TENANT_ID, 'uid' => self::STUDENT_ID, 'cid' => self::CLASS_ID, 'ayid' => $academicYearId],
);
$classAssignment2Id = (string) Uuid::uuid4();
$connection->executeStatement(
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at)
VALUES (:id, :tid, :uid, :cid, :ayid, NOW(), NOW(), NOW())
ON CONFLICT (user_id, academic_year_id) DO NOTHING',
['id' => $classAssignment2Id, 'tid' => self::TENANT_ID, 'uid' => self::STUDENT2_ID, 'cid' => self::CLASS_ID, 'ayid' => $academicYearId],
);
// --- Evaluation (owned by teacher) ---
$evaluation = Evaluation::creer(
tenantId: $tenantId,
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
title: 'Evaluation Competences',
description: null,
evaluationDate: new DateTimeImmutable('2026-03-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
now: $now,
);
$evaluation->pullDomainEvents();
/** @var EvaluationRepository $evalRepo */
$evalRepo = $container->get(EvaluationRepository::class);
$evalRepo->save($evaluation);
$this->evaluationId = $evaluation->id;
// --- Competency framework (default for tenant) ---
$connection->executeStatement(
'INSERT INTO competency_frameworks (id, tenant_id, name, is_default, created_at)
VALUES (:id, :tid, :name, true, NOW())
ON CONFLICT (id) DO NOTHING',
['id' => self::FRAMEWORK_ID, 'tid' => self::TENANT_ID, 'name' => 'Socle commun'],
);
// --- Competencies ---
$connection->executeStatement(
'INSERT INTO competencies (id, framework_id, code, name, description, parent_id, sort_order)
VALUES (:id, :fid, :code, :name, :desc, NULL, :sort)
ON CONFLICT (id) DO NOTHING',
['id' => self::COMPETENCY1_ID, 'fid' => self::FRAMEWORK_ID, 'code' => 'C1', 'name' => 'Lire et comprendre', 'desc' => 'Capacite de lecture', 'sort' => 1],
);
$connection->executeStatement(
'INSERT INTO competencies (id, framework_id, code, name, description, parent_id, sort_order)
VALUES (:id, :fid, :code, :name, :desc, NULL, :sort)
ON CONFLICT (id) DO NOTHING',
['id' => self::COMPETENCY2_ID, 'fid' => self::FRAMEWORK_ID, 'code' => 'C2', 'name' => 'Ecrire', 'desc' => 'Capacite d\'ecriture', 'sort' => 2],
);
// --- Competency evaluations (link competencies to evaluation) ---
$this->competencyEvaluation1Id = (string) Uuid::uuid4();
$connection->executeStatement(
'INSERT INTO competency_evaluations (id, evaluation_id, competency_id)
VALUES (:id, :eid, :cid)
ON CONFLICT (evaluation_id, competency_id) DO NOTHING',
['id' => $this->competencyEvaluation1Id, 'eid' => (string) $this->evaluationId, 'cid' => self::COMPETENCY1_ID],
);
$this->competencyEvaluation2Id = (string) Uuid::uuid4();
$connection->executeStatement(
'INSERT INTO competency_evaluations (id, evaluation_id, competency_id)
VALUES (:id, :eid, :cid)
ON CONFLICT (evaluation_id, competency_id) DO NOTHING',
['id' => $this->competencyEvaluation2Id, 'eid' => (string) $this->evaluationId, 'cid' => self::COMPETENCY2_ID],
);
// --- Student competency result (student1 + competency1 = acquired) ---
$resultId = (string) Uuid::uuid4();
$connection->executeStatement(
'INSERT INTO student_competency_results (id, tenant_id, competency_evaluation_id, student_id, level_code, created_at, updated_at)
VALUES (:id, :tid, :ceid, :sid, :level, NOW(), NOW())
ON CONFLICT (competency_evaluation_id, student_id) DO NOTHING',
[
'id' => $resultId,
'tid' => self::TENANT_ID,
'ceid' => $this->competencyEvaluation1Id,
'sid' => self::STUDENT_ID,
'level' => 'acquired',
],
);
}
}