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.
895 lines
38 KiB
PHP
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',
|
|
],
|
|
);
|
|
}
|
|
}
|