feat: Permettre à l'élève de consulter ses notes et moyennes
L'élève avait accès à ses compétences mais pas à ses notes numériques. Cette fonctionnalité lui donne une vue complète de sa progression scolaire avec moyennes par matière, détail par évaluation, statistiques de classe, et un mode "découverte" pour révéler ses notes à son rythme (FR14, FR15). Les notes ne sont visibles qu'après publication par l'enseignant, ce qui garantit que l'élève les découvre avant ses parents (délai 24h story 6.7).
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\StudentGradeResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function in_array;
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<StudentGradeResource>
|
||||
*/
|
||||
final readonly class StudentGradeCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TenantContext $tenantContext,
|
||||
private Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<StudentGradeResource> */
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
if (!in_array(Role::ELEVE->value, $user->getRoles(), true)) {
|
||||
throw new AccessDeniedHttpException('Accès réservé aux élèves.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$studentId = $user->userId();
|
||||
|
||||
/** @var string|null $subjectId */
|
||||
$subjectId = $uriVariables['subjectId'] ?? null;
|
||||
|
||||
if (is_string($subjectId) && $subjectId === '') {
|
||||
$subjectId = null;
|
||||
}
|
||||
|
||||
$subjectFilter = $subjectId !== null ? 'AND s.id = :subject_id' : '';
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
"SELECT g.id AS grade_id, g.value, g.status AS grade_status, g.appreciation,
|
||||
e.id AS evaluation_id, e.title AS evaluation_title,
|
||||
e.evaluation_date, e.grade_scale, e.coefficient,
|
||||
e.grades_published_at,
|
||||
s.id AS subject_id, s.name AS subject_name,
|
||||
es.average AS class_average, es.min_grade AS class_min, es.max_grade AS class_max
|
||||
FROM grades g
|
||||
JOIN evaluations e ON g.evaluation_id = e.id
|
||||
JOIN subjects s ON e.subject_id = s.id
|
||||
LEFT JOIN evaluation_statistics es ON es.evaluation_id = e.id
|
||||
WHERE g.student_id = :student_id
|
||||
AND g.tenant_id = :tenant_id
|
||||
AND e.grades_published_at IS NOT NULL
|
||||
AND e.status != :deleted_status
|
||||
{$subjectFilter}
|
||||
ORDER BY e.evaluation_date DESC, e.created_at DESC",
|
||||
$subjectId !== null
|
||||
? ['student_id' => $studentId, 'tenant_id' => $tenantId, 'deleted_status' => 'deleted', 'subject_id' => $subjectId]
|
||||
: ['student_id' => $studentId, 'tenant_id' => $tenantId, 'deleted_status' => 'deleted'],
|
||||
);
|
||||
|
||||
return array_map(self::hydrateResource(...), $rows);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private static function hydrateResource(array $row): StudentGradeResource
|
||||
{
|
||||
$resource = new StudentGradeResource();
|
||||
|
||||
/** @var string $gradeId */
|
||||
$gradeId = $row['grade_id'];
|
||||
$resource->id = $gradeId;
|
||||
|
||||
/** @var string $evaluationId */
|
||||
$evaluationId = $row['evaluation_id'];
|
||||
$resource->evaluationId = $evaluationId;
|
||||
|
||||
/** @var string $evaluationTitle */
|
||||
$evaluationTitle = $row['evaluation_title'];
|
||||
$resource->evaluationTitle = $evaluationTitle;
|
||||
|
||||
/** @var string $evaluationDate */
|
||||
$evaluationDate = $row['evaluation_date'];
|
||||
$resource->evaluationDate = $evaluationDate;
|
||||
|
||||
/** @var string|int $gradeScale */
|
||||
$gradeScale = $row['grade_scale'];
|
||||
$resource->gradeScale = (int) $gradeScale;
|
||||
|
||||
/** @var string|float $coefficient */
|
||||
$coefficient = $row['coefficient'];
|
||||
$resource->coefficient = (float) $coefficient;
|
||||
|
||||
/** @var string $subjectIdVal */
|
||||
$subjectIdVal = $row['subject_id'];
|
||||
$resource->subjectId = $subjectIdVal;
|
||||
|
||||
/** @var string|null $subjectName */
|
||||
$subjectName = $row['subject_name'];
|
||||
$resource->subjectName = $subjectName;
|
||||
|
||||
/** @var string|float|null $value */
|
||||
$value = $row['value'];
|
||||
$resource->value = $value !== null ? (float) $value : null;
|
||||
|
||||
/** @var string $gradeStatus */
|
||||
$gradeStatus = $row['grade_status'];
|
||||
$resource->status = $gradeStatus;
|
||||
|
||||
/** @var string|null $appreciation */
|
||||
$appreciation = $row['appreciation'];
|
||||
$resource->appreciation = $appreciation;
|
||||
|
||||
/** @var string|null $publishedAt */
|
||||
$publishedAt = $row['grades_published_at'];
|
||||
$resource->publishedAt = $publishedAt;
|
||||
|
||||
/** @var string|float|null $classAverage */
|
||||
$classAverage = $row['class_average'];
|
||||
$resource->classAverage = $classAverage !== null ? (float) $classAverage : null;
|
||||
|
||||
/** @var string|float|null $classMin */
|
||||
$classMin = $row['class_min'];
|
||||
$resource->classMin = $classMin !== null ? (float) $classMin : null;
|
||||
|
||||
/** @var string|float|null $classMax */
|
||||
$classMax = $row['class_max'];
|
||||
$resource->classMax = $classMax !== null ? (float) $classMax : null;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Domain\Repository\StudentAverageRepository;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\StudentMyAveragesResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<StudentMyAveragesResource>
|
||||
*/
|
||||
final readonly class StudentMyAveragesProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private StudentAverageRepository $studentAverageRepository,
|
||||
private PeriodFinder $periodFinder,
|
||||
private TenantContext $tenantContext,
|
||||
private Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): StudentMyAveragesResource
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
if (!in_array(Role::ELEVE->value, $user->getRoles(), true)) {
|
||||
throw new AccessDeniedHttpException('Accès réservé aux élèves.');
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantContext->getCurrentTenantId();
|
||||
$studentId = UserId::fromString($user->userId());
|
||||
|
||||
/** @var array<string, mixed> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
/** @var string|null $periodId */
|
||||
$periodId = $filters['periodId'] ?? null;
|
||||
|
||||
// Auto-detect current period if not specified
|
||||
if ($periodId === null) {
|
||||
$periodInfo = $this->periodFinder->findForDate(new DateTimeImmutable(), $tenantId);
|
||||
|
||||
if ($periodInfo !== null) {
|
||||
$periodId = $periodInfo->periodId;
|
||||
}
|
||||
}
|
||||
|
||||
$resource = new StudentMyAveragesResource();
|
||||
$resource->studentId = $user->userId();
|
||||
$resource->periodId = $periodId;
|
||||
|
||||
if ($periodId === null) {
|
||||
return $resource;
|
||||
}
|
||||
|
||||
$resource->subjectAverages = $this->studentAverageRepository->findDetailedSubjectAveragesForStudent(
|
||||
$studentId,
|
||||
$periodId,
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
$resource->generalAverage = $this->studentAverageRepository->findGeneralAverageForStudent(
|
||||
$studentId,
|
||||
$periodId,
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\StudentGradeCollectionProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'StudentGrade',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/me/grades',
|
||||
provider: StudentGradeCollectionProvider::class,
|
||||
name: 'get_my_grades',
|
||||
),
|
||||
new GetCollection(
|
||||
uriTemplate: '/me/grades/subject/{subjectId}',
|
||||
uriVariables: ['subjectId'],
|
||||
provider: StudentGradeCollectionProvider::class,
|
||||
name: 'get_my_grades_by_subject',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class StudentGradeResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
public ?string $evaluationId = null;
|
||||
|
||||
public ?string $evaluationTitle = null;
|
||||
|
||||
public ?string $evaluationDate = null;
|
||||
|
||||
public ?int $gradeScale = null;
|
||||
|
||||
public ?float $coefficient = null;
|
||||
|
||||
public ?string $subjectId = null;
|
||||
|
||||
public ?string $subjectName = null;
|
||||
|
||||
public ?float $value = null;
|
||||
|
||||
public ?string $status = null;
|
||||
|
||||
public ?string $appreciation = null;
|
||||
|
||||
public ?string $publishedAt = null;
|
||||
|
||||
public ?float $classAverage = null;
|
||||
|
||||
public ?float $classMin = null;
|
||||
|
||||
public ?float $classMax = null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\StudentMyAveragesProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'StudentMyAverages',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/me/averages',
|
||||
provider: StudentMyAveragesProvider::class,
|
||||
name: 'get_my_averages',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class StudentMyAveragesResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $studentId = 'me';
|
||||
|
||||
public ?string $periodId = null;
|
||||
|
||||
/** @var list<array{subjectId: string, subjectName: string|null, average: float, gradeCount: int}> */
|
||||
public array $subjectAverages = [];
|
||||
|
||||
public ?float $generalAverage = null;
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
<?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 const JSON_THROW_ON_ERROR;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
final class StudentGradeEndpointsTest 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 STUDENT_ID = '22222222-2222-2222-2222-222222222222';
|
||||
private const string STUDENT2_ID = '33333333-3333-3333-3333-333333333333';
|
||||
private const string CLASS_ID = '55555555-5555-5555-5555-555555555555';
|
||||
private const string SUBJECT_ID = '66666666-6666-6666-6666-666666666666';
|
||||
private const string SUBJECT2_ID = '66666666-6666-6666-6666-666666666667';
|
||||
private const string PERIOD_ID = '11111111-1111-1111-1111-111111111111';
|
||||
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
|
||||
|
||||
private ?EvaluationId $unpublishedEvalId = 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 grade_events WHERE grade_id IN (SELECT id FROM grades 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 /me/grades — Auth & Access
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesReturns403ForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesReturns403ForParent(): void
|
||||
{
|
||||
$parentId = '88888888-8888-8888-8888-888888888888';
|
||||
$client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/grades — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesReturnsPublishedGradesForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var list<array<string, mixed>> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// Only published grades should be returned (not unpublished)
|
||||
self::assertCount(2, $data);
|
||||
|
||||
// First grade (sorted by eval date DESC, subject2 is more recent)
|
||||
self::assertSame(self::SUBJECT2_ID, $data[0]['subjectId']);
|
||||
self::assertSame(14.0, $data[0]['value']);
|
||||
self::assertSame('graded', $data[0]['status']);
|
||||
self::assertNotNull($data[0]['publishedAt']);
|
||||
|
||||
// Second grade
|
||||
self::assertSame(self::SUBJECT_ID, $data[1]['subjectId']);
|
||||
self::assertSame(16.0, $data[1]['value']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesDoesNotReturnUnpublishedGrades(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var list<array<string, mixed>> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// The unpublished evaluation grade should not appear
|
||||
foreach ($data as $grade) {
|
||||
self::assertNotSame((string) $this->unpublishedEvalId, $grade['evaluationId']);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesIncludesClassStatistics(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var list<array<string, mixed>> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// First grade should have class statistics
|
||||
self::assertArrayHasKey('classAverage', $data[0]);
|
||||
self::assertArrayHasKey('classMin', $data[0]);
|
||||
self::assertArrayHasKey('classMax', $data[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesReturnsEmptyForStudentWithNoGrades(): void
|
||||
{
|
||||
$noGradeStudentId = '77777777-7777-7777-7777-777777777777';
|
||||
/** @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, 'no-grade@test.local', '', 'No', 'Grades', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => $noGradeStudentId, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
|
||||
$client = $this->createAuthenticatedClient($noGradeStudentId, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var list<mixed> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertCount(0, $data);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/grades/subject/{subjectId} — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesBySubjectFiltersCorrectly(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades/subject/' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var list<array<string, mixed>> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertCount(1, $data);
|
||||
self::assertSame(self::SUBJECT_ID, $data[0]['subjectId']);
|
||||
self::assertSame(16.0, $data[0]['value']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesBySubjectReturnsEmptyForUnknownSubject(): void
|
||||
{
|
||||
$unknownSubjectId = '99999999-9999-9999-9999-999999999999';
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades/subject/' . $unknownSubjectId, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var list<mixed> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertCount(0, $data);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/averages — Auth & Access
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getMyAveragesReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/averages', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getMyAveragesReturns403ForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/averages', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/averages — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getMyAveragesReturnsAveragesForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertJsonContains([
|
||||
'studentId' => self::STUDENT_ID,
|
||||
'periodId' => self::PERIOD_ID,
|
||||
'generalAverage' => 16.0,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getMyAveragesReturnsSubjectAverages(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array{subjectAverages: list<array<string, mixed>>, generalAverage: float|null} $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertNotEmpty($data['subjectAverages']);
|
||||
self::assertSame(self::SUBJECT_ID, $data['subjectAverages'][0]['subjectId']);
|
||||
self::assertSame(16.0, $data['subjectAverages'][0]['average']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/grades — Student isolation
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesReturnsOnlyCurrentStudentGrades(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT2_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var list<array<string, mixed>> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// Student2 only has 1 grade (eval1, Maths), not the eval2/eval3 grades
|
||||
self::assertCount(1, $data);
|
||||
self::assertSame(12.0, $data[0]['value']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/grades — Response completeness
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesReturnsAllExpectedFields(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var list<array<string, mixed>> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// First grade (eval2 — Français, more recent)
|
||||
$grade = $data[0];
|
||||
self::assertArrayHasKey('id', $grade);
|
||||
self::assertArrayHasKey('evaluationId', $grade);
|
||||
self::assertArrayHasKey('evaluationTitle', $grade);
|
||||
self::assertArrayHasKey('evaluationDate', $grade);
|
||||
self::assertArrayHasKey('gradeScale', $grade);
|
||||
self::assertArrayHasKey('coefficient', $grade);
|
||||
self::assertArrayHasKey('subjectId', $grade);
|
||||
self::assertArrayHasKey('value', $grade);
|
||||
self::assertArrayHasKey('status', $grade);
|
||||
self::assertArrayHasKey('publishedAt', $grade);
|
||||
|
||||
self::assertSame('Dictée', $grade['evaluationTitle']);
|
||||
self::assertSame(20, $grade['gradeScale']);
|
||||
self::assertSame(2.0, $grade['coefficient']);
|
||||
self::assertSame('Français', $grade['subjectName'] ?? null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getMyGradesIncludesAppreciationWhenSet(): void
|
||||
{
|
||||
// Add appreciation to eval1 grade
|
||||
/** @var Connection $connection */
|
||||
$connection = static::getContainer()->get(Connection::class);
|
||||
$connection->executeStatement(
|
||||
"UPDATE grades SET appreciation = 'Excellent travail' WHERE student_id = :sid AND evaluation_id IN (SELECT id FROM evaluations WHERE title = 'DS Mathématiques' AND tenant_id = :tid)",
|
||||
['sid' => self::STUDENT_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var list<array<string, mixed>> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// Find the Maths grade (eval1)
|
||||
$mathsGrade = null;
|
||||
foreach ($data as $grade) {
|
||||
if (($grade['evaluationTitle'] ?? null) === 'DS Mathématiques') {
|
||||
$mathsGrade = $grade;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
self::assertNotNull($mathsGrade, 'DS Mathématiques grade not found');
|
||||
self::assertSame('Excellent travail', $mathsGrade['appreciation']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/averages — Auto-detect period
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getMyAveragesReturnsEmptyWhenNoPeriodCoversCurrentDate(): void
|
||||
{
|
||||
// The seeded period (2026-01-01 to 2026-03-31) does not cover today (2026-04-04)
|
||||
// So auto-detect returns no period → empty averages
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/averages', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('studentId', $data);
|
||||
self::assertEmpty($data['subjectAverages'] ?? []);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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 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-sg@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-sg@test.local', '', 'Alice', 'Durand', '[\"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-sg@test.local', '', 'Bob', 'Martin', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::STUDENT2_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
|
||||
// Seed class and subjects
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at)
|
||||
VALUES (:id, :tid, :sid, :ayid, 'Test-SG-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, 'Mathématiques', 'MATH', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
|
||||
VALUES (:id, :tid, :sid, 'Français', 'FRA', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::SUBJECT2_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],
|
||||
);
|
||||
|
||||
/** @var EvaluationRepository $evalRepo */
|
||||
$evalRepo = $container->get(EvaluationRepository::class);
|
||||
/** @var GradeRepository $gradeRepo */
|
||||
$gradeRepo = $container->get(GradeRepository::class);
|
||||
/** @var AverageCalculator $calculator */
|
||||
$calculator = $container->get(AverageCalculator::class);
|
||||
/** @var EvaluationStatisticsRepository $statsRepo */
|
||||
$statsRepo = $container->get(EvaluationStatisticsRepository::class);
|
||||
|
||||
// Evaluation 1: Published, Subject 1 (Maths), older date
|
||||
$eval1 = Evaluation::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'DS Mathématiques',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(1.0),
|
||||
now: $now,
|
||||
);
|
||||
$eval1->publierNotes($now);
|
||||
$eval1->pullDomainEvents();
|
||||
$evalRepo->save($eval1);
|
||||
|
||||
foreach ([
|
||||
[self::STUDENT_ID, 16.0],
|
||||
[self::STUDENT2_ID, 12.0],
|
||||
] as [$studentId, $value]) {
|
||||
$grade = Grade::saisir(
|
||||
tenantId: $tenantId,
|
||||
evaluationId: $eval1->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);
|
||||
}
|
||||
|
||||
$stats1 = $calculator->calculateClassStatistics([16.0, 12.0]);
|
||||
$statsRepo->save($eval1->id, $stats1);
|
||||
|
||||
// Evaluation 2: Published, Subject 2 (Français), more recent date
|
||||
$eval2 = Evaluation::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT2_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'Dictée',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-03-01'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(2.0),
|
||||
now: $now,
|
||||
);
|
||||
$eval2->publierNotes($now);
|
||||
$eval2->pullDomainEvents();
|
||||
$evalRepo->save($eval2);
|
||||
|
||||
$grade2 = Grade::saisir(
|
||||
tenantId: $tenantId,
|
||||
evaluationId: $eval2->id,
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
value: new GradeValue(14.0),
|
||||
status: GradeStatus::GRADED,
|
||||
gradeScale: new GradeScale(20),
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: $now,
|
||||
);
|
||||
$grade2->pullDomainEvents();
|
||||
$gradeRepo->save($grade2);
|
||||
|
||||
$stats2 = $calculator->calculateClassStatistics([14.0]);
|
||||
$statsRepo->save($eval2->id, $stats2);
|
||||
|
||||
// Evaluation 3: NOT published (grades should NOT appear for student)
|
||||
$eval3 = Evaluation::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'Contrôle surprise',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-03-10'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(0.5),
|
||||
now: $now,
|
||||
);
|
||||
// NOT published - don't call publierNotes()
|
||||
$eval3->pullDomainEvents();
|
||||
$evalRepo->save($eval3);
|
||||
$this->unpublishedEvalId = $eval3->id;
|
||||
|
||||
$grade3 = Grade::saisir(
|
||||
tenantId: $tenantId,
|
||||
evaluationId: $eval3->id,
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
value: new GradeValue(8.0),
|
||||
status: GradeStatus::GRADED,
|
||||
gradeScale: new GradeScale(20),
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: $now,
|
||||
);
|
||||
$grade3->pullDomainEvents();
|
||||
$gradeRepo->save($grade3);
|
||||
|
||||
// Save student averages for /me/averages endpoint
|
||||
/** @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