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.
This commit is contained in:
@@ -0,0 +1,894 @@
|
||||
<?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',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
394
backend/tests/Functional/Scolarite/Api/MoyennesEndpointsTest.php
Normal file
394
backend/tests/Functional/Scolarite/Api/MoyennesEndpointsTest.php
Normal file
@@ -0,0 +1,394 @@
|
||||
<?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\EvaluationId;
|
||||
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\Repository\StudentAverageRepository;
|
||||
use App\Scolarite\Domain\Service\AverageCalculator;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
final class MoyennesEndpointsTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string TEACHER_ID = '44444444-4444-4444-4444-444444444444';
|
||||
private const string OTHER_TEACHER_ID = '44444444-4444-4444-4444-444444444445';
|
||||
private const string STUDENT_ID = '22222222-2222-2222-2222-222222222222';
|
||||
private const string CLASS_ID = '55555555-5555-5555-5555-555555555555';
|
||||
private const string SUBJECT_ID = '66666666-6666-6666-6666-666666666666';
|
||||
private const string PERIOD_ID = '11111111-1111-1111-1111-111111111111';
|
||||
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
|
||||
|
||||
private ?EvaluationId $evaluationId = 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 evaluation_statistics WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid)', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM student_general_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM student_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /evaluations/{id}/statistics — Auth
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getEvaluationStatisticsReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getEvaluationStatisticsReturns403ForNonOwner(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /evaluations/{id}/statistics — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getEvaluationStatisticsReturnsStatsForOwner(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertJsonContains([
|
||||
'evaluationId' => (string) $this->evaluationId,
|
||||
'gradedCount' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getEvaluationStatisticsReturns404ForUnknownEvaluation(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$unknownId = (string) EvaluationId::generate();
|
||||
$client->request('GET', self::BASE_URL . '/evaluations/' . $unknownId . '/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /students/{id}/averages — Auth
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getStudentAveragesReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getStudentAveragesReturns403ForUnrelatedParent(): void
|
||||
{
|
||||
$parentId = '88888888-8888-8888-8888-888888888888';
|
||||
$client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /students/{id}/averages — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getStudentAveragesReturnsDataForStaff(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertJsonContains([
|
||||
'studentId' => self::STUDENT_ID,
|
||||
'periodId' => self::PERIOD_ID,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getStudentAveragesReturnsOwnDataForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertJsonContains([
|
||||
'studentId' => self::STUDENT_ID,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /classes/{id}/statistics — Auth
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getClassStatisticsReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getClassStatisticsReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getClassStatisticsReturns403ForParent(): void
|
||||
{
|
||||
$parentId = '88888888-8888-8888-8888-888888888888';
|
||||
$client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /classes/{id}/statistics — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getClassStatisticsReturnsDataForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertJsonContains([
|
||||
'classId' => self::CLASS_ID,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getClassStatisticsReturnsDataForAdmin(): void
|
||||
{
|
||||
$adminId = '99999999-9999-9999-9999-999999999999';
|
||||
$client = $this->createAuthenticatedClient($adminId, ['ROLE_ADMIN']);
|
||||
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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@classeo.local',
|
||||
hashedPassword: '',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: $roles,
|
||||
);
|
||||
|
||||
$client->loginUser($user, 'api');
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function seedFixtures(): void
|
||||
{
|
||||
$container = static::getContainer();
|
||||
/** @var Connection $connection */
|
||||
$connection = $container->get(Connection::class);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
$schoolId = '550e8400-e29b-41d4-a716-ff6655440001';
|
||||
$academicYearId = '550e8400-e29b-41d4-a716-ff6655440002';
|
||||
|
||||
// Seed parent tables
|
||||
$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-moy@test.local', '', 'Test', 'Teacher', '[\"ROLE_PROF\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||
VALUES (:id, :tid, 'student-moy@test.local', '', 'Test', 'Student', '[\"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-moy@test.local', '', 'Test', 'Student2', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => '33333333-3333-3333-3333-333333333333', 'tid' => self::TENANT_ID],
|
||||
);
|
||||
$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-Moy-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, 'Test-Moy-Subject', 'TMOY', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO academic_periods (id, tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date)
|
||||
VALUES (:id, :tid, :ayid, 'trimester', 2, 'Trimestre 2', '2026-01-01', '2026-03-31')
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::PERIOD_ID, 'tid' => self::TENANT_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
|
||||
// Créer une évaluation publiée avec 2 notes
|
||||
$evaluation = Evaluation::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'DS Mathématiques',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(1.0),
|
||||
now: $now,
|
||||
);
|
||||
$evaluation->publierNotes($now);
|
||||
$evaluation->pullDomainEvents();
|
||||
|
||||
/** @var EvaluationRepository $evalRepo */
|
||||
$evalRepo = $container->get(EvaluationRepository::class);
|
||||
$evalRepo->save($evaluation);
|
||||
|
||||
$this->evaluationId = $evaluation->id;
|
||||
|
||||
/** @var GradeRepository $gradeRepo */
|
||||
$gradeRepo = $container->get(GradeRepository::class);
|
||||
|
||||
$student2Id = '33333333-3333-3333-3333-333333333333';
|
||||
|
||||
foreach ([
|
||||
[self::STUDENT_ID, 16.0],
|
||||
[$student2Id, 12.0],
|
||||
] as [$studentId, $value]) {
|
||||
$grade = Grade::saisir(
|
||||
tenantId: $tenantId,
|
||||
evaluationId: $evaluation->id,
|
||||
studentId: UserId::fromString($studentId),
|
||||
value: new GradeValue($value),
|
||||
status: GradeStatus::GRADED,
|
||||
gradeScale: new GradeScale(20),
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: $now,
|
||||
);
|
||||
$grade->pullDomainEvents();
|
||||
$gradeRepo->save($grade);
|
||||
}
|
||||
|
||||
// Calculer et sauvegarder les statistiques
|
||||
/** @var AverageCalculator $calculator */
|
||||
$calculator = $container->get(AverageCalculator::class);
|
||||
|
||||
$stats = $calculator->calculateClassStatistics([16.0, 12.0]);
|
||||
|
||||
/** @var EvaluationStatisticsRepository $statsRepo */
|
||||
$statsRepo = $container->get(EvaluationStatisticsRepository::class);
|
||||
$statsRepo->save($evaluation->id, $stats);
|
||||
|
||||
// Sauvegarder une moyenne élève
|
||||
/** @var StudentAverageRepository $avgRepo */
|
||||
$avgRepo = $container->get(StudentAverageRepository::class);
|
||||
$avgRepo->saveSubjectAverage(
|
||||
$tenantId,
|
||||
UserId::fromString(self::STUDENT_ID),
|
||||
SubjectId::fromString(self::SUBJECT_ID),
|
||||
self::PERIOD_ID,
|
||||
16.0,
|
||||
1,
|
||||
);
|
||||
$avgRepo->saveGeneralAverage(
|
||||
$tenantId,
|
||||
UserId::fromString(self::STUDENT_ID),
|
||||
self::PERIOD_ID,
|
||||
16.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user