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.
395 lines
16 KiB
PHP
395 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Scolarite\Api;
|
|
|
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
|
use App\Scolarite\Domain\Model\Evaluation\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,
|
|
);
|
|
}
|
|
}
|