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:
@@ -98,6 +98,13 @@ services:
|
||||
App\Administration\Domain\Policy\ConsentementParentalPolicy:
|
||||
autowire: true
|
||||
|
||||
App\Scolarite\Domain\Policy\VisibiliteNotesPolicy:
|
||||
autowire: true
|
||||
|
||||
# Ports
|
||||
App\Scolarite\Application\Port\ParentGradeDelayReader:
|
||||
alias: App\Scolarite\Infrastructure\Service\DatabaseParentGradeDelayReader
|
||||
|
||||
# Email handlers
|
||||
App\Administration\Infrastructure\Messaging\SendActivationConfirmationHandler:
|
||||
arguments:
|
||||
|
||||
30
backend/migrations/Version20260406074932.php
Normal file
30
backend/migrations/Version20260406074932.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260406074932 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Ajouter le délai configurable de visibilité des notes pour les parents';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE school_grading_configurations
|
||||
ADD COLUMN parent_grade_delay_hours SMALLINT NOT NULL DEFAULT 24
|
||||
CHECK (parent_grade_delay_hours >= 0 AND parent_grade_delay_hours <= 72)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE school_grading_configurations DROP COLUMN parent_grade_delay_hours');
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,14 @@ final class SchoolClass extends AggregateRoot
|
||||
|
||||
$this->level = $niveau;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new ClasseModifiee(
|
||||
classId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
ancienNom: $this->name,
|
||||
nouveauNom: $this->name,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour lire le délai de visibilité des notes pour les parents.
|
||||
*
|
||||
* Le délai détermine combien d'heures après publication les notes
|
||||
* deviennent visibles pour les parents (0 = immédiat, max 72h).
|
||||
*/
|
||||
interface ParentGradeDelayReader
|
||||
{
|
||||
public function delayHoursForTenant(TenantId $tenantId): int;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class ChildGradesDto
|
||||
{
|
||||
/**
|
||||
* @param array<ParentGradeDto> $grades
|
||||
*/
|
||||
public function __construct(
|
||||
public string $childId,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public array $grades,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class ChildGradesSummaryDto
|
||||
{
|
||||
/**
|
||||
* @param list<array{subjectId: string, subjectName: string|null, average: float, gradeCount: int}> $subjectAverages
|
||||
*/
|
||||
public function __construct(
|
||||
public string $childId,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public ?string $periodId,
|
||||
public array $subjectAverages,
|
||||
public ?float $generalAverage,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\ParentChildrenReader;
|
||||
use App\Scolarite\Application\Port\ParentGradeDelayReader;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||
use App\Scolarite\Domain\Policy\VisibiliteNotesPolicy;
|
||||
use App\Scolarite\Domain\Repository\EvaluationRepository;
|
||||
use App\Scolarite\Domain\Repository\EvaluationStatisticsRepository;
|
||||
use App\Scolarite\Domain\Repository\GradeRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function array_values;
|
||||
use function usort;
|
||||
|
||||
final readonly class GetChildrenGradesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ParentChildrenReader $parentChildrenReader,
|
||||
private EvaluationRepository $evaluationRepository,
|
||||
private GradeRepository $gradeRepository,
|
||||
private EvaluationStatisticsRepository $statisticsRepository,
|
||||
private ScheduleDisplayReader $displayReader,
|
||||
private VisibiliteNotesPolicy $visibiliteNotesPolicy,
|
||||
private ParentGradeDelayReader $delayReader,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ChildGradesDto> */
|
||||
public function __invoke(GetChildrenGradesQuery $query): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
$allChildren = $this->parentChildrenReader->childrenOf($query->parentId, $tenantId);
|
||||
|
||||
if ($allChildren === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$children = $query->childId !== null
|
||||
? array_values(array_filter($allChildren, static fn (array $c): bool => $c['studentId'] === $query->childId))
|
||||
: $allChildren;
|
||||
|
||||
if ($children === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$delaiHeures = $this->delayReader->delayHoursForTenant($tenantId);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($children as $child) {
|
||||
$studentId = UserId::fromString($child['studentId']);
|
||||
|
||||
// Get all grades for this student (not filtered by class)
|
||||
$studentGrades = $this->gradeRepository->findByStudent($studentId, $tenantId);
|
||||
|
||||
if ($studentGrades === []) {
|
||||
$result[] = new ChildGradesDto(
|
||||
childId: $child['studentId'],
|
||||
firstName: $child['firstName'],
|
||||
lastName: $child['lastName'],
|
||||
grades: [],
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect unique evaluation IDs and load evaluations
|
||||
$evaluationIds = array_values(array_unique(
|
||||
array_map(static fn (Grade $g): string => (string) $g->evaluationId, $studentGrades),
|
||||
));
|
||||
|
||||
$evaluationsById = [];
|
||||
|
||||
foreach ($evaluationIds as $evalIdStr) {
|
||||
$evaluation = $this->evaluationRepository->findById(
|
||||
EvaluationId::fromString($evalIdStr),
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
if ($evaluation !== null) {
|
||||
$evaluationsById[$evalIdStr] = $evaluation;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter evaluations visible to parents (published + delay elapsed)
|
||||
$visibleEvaluationIds = [];
|
||||
|
||||
foreach ($evaluationsById as $evalIdStr => $evaluation) {
|
||||
if ($this->visibiliteNotesPolicy->visiblePourParent($evaluation, $delaiHeures)) {
|
||||
$visibleEvaluationIds[$evalIdStr] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by subject if requested
|
||||
if ($query->subjectId !== null) {
|
||||
$filterSubjectId = $query->subjectId;
|
||||
|
||||
foreach ($visibleEvaluationIds as $evalIdStr => $_) {
|
||||
$evaluation = $evaluationsById[$evalIdStr];
|
||||
|
||||
if ((string) $evaluation->subjectId !== $filterSubjectId) {
|
||||
unset($visibleEvaluationIds[$evalIdStr]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($visibleEvaluationIds === []) {
|
||||
$result[] = new ChildGradesDto(
|
||||
childId: $child['studentId'],
|
||||
firstName: $child['firstName'],
|
||||
lastName: $child['lastName'],
|
||||
grades: [],
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve display names
|
||||
$visibleEvaluations = array_values(array_filter(
|
||||
$evaluationsById,
|
||||
static fn (Evaluation $e): bool => isset($visibleEvaluationIds[(string) $e->id]),
|
||||
));
|
||||
$subjectIds = array_values(array_unique(
|
||||
array_map(static fn (Evaluation $e): string => (string) $e->subjectId, $visibleEvaluations),
|
||||
));
|
||||
$subjects = $this->displayReader->subjectDisplay($query->tenantId, ...$subjectIds);
|
||||
|
||||
// Build grade DTOs
|
||||
$childGrades = [];
|
||||
|
||||
foreach ($studentGrades as $grade) {
|
||||
$evalIdStr = (string) $grade->evaluationId;
|
||||
|
||||
if (!isset($visibleEvaluationIds[$evalIdStr])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$evaluation = $evaluationsById[$evalIdStr];
|
||||
$subjectInfo = $subjects[(string) $evaluation->subjectId] ?? ['name' => null, 'color' => null];
|
||||
$statistics = $this->statisticsRepository->findByEvaluation($evaluation->id);
|
||||
|
||||
$childGrades[] = new ParentGradeDto(
|
||||
id: (string) $grade->id,
|
||||
evaluationId: $evalIdStr,
|
||||
evaluationTitle: $evaluation->title,
|
||||
evaluationDate: $evaluation->evaluationDate->format('Y-m-d'),
|
||||
gradeScale: $evaluation->gradeScale->maxValue,
|
||||
coefficient: $evaluation->coefficient->value,
|
||||
subjectId: (string) $evaluation->subjectId,
|
||||
subjectName: $subjectInfo['name'],
|
||||
subjectColor: $subjectInfo['color'],
|
||||
value: $grade->value?->value,
|
||||
status: $grade->status->value,
|
||||
appreciation: $grade->appreciation,
|
||||
publishedAt: $evaluation->gradesPublishedAt?->format('Y-m-d\TH:i:sP') ?? '',
|
||||
classAverage: $statistics?->average,
|
||||
classMin: $statistics?->min,
|
||||
classMax: $statistics?->max,
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by evaluation date descending
|
||||
usort($childGrades, static fn (ParentGradeDto $a, ParentGradeDto $b): int => $b->evaluationDate <=> $a->evaluationDate);
|
||||
|
||||
$result[] = new ChildGradesDto(
|
||||
childId: $child['studentId'],
|
||||
firstName: $child['firstName'],
|
||||
lastName: $child['lastName'],
|
||||
grades: $childGrades,
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class GetChildrenGradesQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $parentId,
|
||||
public string $tenantId,
|
||||
public ?string $childId = null,
|
||||
public ?string $subjectId = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
use function array_sum;
|
||||
use function count;
|
||||
use function round;
|
||||
|
||||
/**
|
||||
* Calcule les moyennes par matière et la moyenne générale
|
||||
* à partir des notes visibles (respectant le délai parent).
|
||||
*
|
||||
* Ne lit PAS les agrégats pré-calculés (student_averages) car ceux-ci
|
||||
* incluent des notes encore dans la période de délai.
|
||||
*/
|
||||
final readonly class GetChildrenGradesSummaryHandler
|
||||
{
|
||||
public function __construct(
|
||||
private GetChildrenGradesHandler $gradesHandler,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ChildGradesSummaryDto> */
|
||||
public function __invoke(GetChildrenGradesSummaryQuery $query): array
|
||||
{
|
||||
// Réutilise le handler notes qui applique le délai de visibilité
|
||||
$childrenGrades = ($this->gradesHandler)(new GetChildrenGradesQuery(
|
||||
parentId: $query->parentId,
|
||||
tenantId: $query->tenantId,
|
||||
));
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($childrenGrades as $child) {
|
||||
// Grouper par matière et calculer les moyennes pondérées
|
||||
$subjectData = [];
|
||||
|
||||
foreach ($child->grades as $grade) {
|
||||
if ($grade->value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $grade->subjectId;
|
||||
|
||||
if (!isset($subjectData[$key])) {
|
||||
$subjectData[$key] = [
|
||||
'subjectId' => $grade->subjectId,
|
||||
'subjectName' => $grade->subjectName,
|
||||
'weightedSum' => 0.0,
|
||||
'coefficientSum' => 0.0,
|
||||
'gradeCount' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$subjectData[$key]['weightedSum'] += $grade->value * $grade->coefficient;
|
||||
$subjectData[$key]['coefficientSum'] += $grade->coefficient;
|
||||
++$subjectData[$key]['gradeCount'];
|
||||
}
|
||||
|
||||
$subjectAverages = [];
|
||||
|
||||
foreach ($subjectData as $data) {
|
||||
if ($data['coefficientSum'] > 0) {
|
||||
$subjectAverages[] = [
|
||||
'subjectId' => $data['subjectId'],
|
||||
'subjectName' => $data['subjectName'],
|
||||
'average' => round($data['weightedSum'] / $data['coefficientSum'], 2),
|
||||
'gradeCount' => $data['gradeCount'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$generalAverage = null;
|
||||
|
||||
if ($subjectAverages !== []) {
|
||||
$averages = array_map(static fn (array $a): float => $a['average'], $subjectAverages);
|
||||
$generalAverage = round(array_sum($averages) / count($averages), 2);
|
||||
}
|
||||
|
||||
$result[] = new ChildGradesSummaryDto(
|
||||
childId: $child->childId,
|
||||
firstName: $child->firstName,
|
||||
lastName: $child->lastName,
|
||||
periodId: null,
|
||||
subjectAverages: $subjectAverages,
|
||||
generalAverage: $generalAverage,
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class GetChildrenGradesSummaryQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $parentId,
|
||||
public string $tenantId,
|
||||
public ?string $periodId = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class ParentGradeDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $evaluationId,
|
||||
public string $evaluationTitle,
|
||||
public string $evaluationDate,
|
||||
public int $gradeScale,
|
||||
public float $coefficient,
|
||||
public string $subjectId,
|
||||
public ?string $subjectName,
|
||||
public ?string $subjectColor,
|
||||
public ?float $value,
|
||||
public string $status,
|
||||
public ?string $appreciation,
|
||||
public string $publishedAt,
|
||||
public ?float $classAverage,
|
||||
public ?float $classMin,
|
||||
public ?float $classMax,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ namespace App\Scolarite\Domain\Policy;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
use App\Shared\Domain\Clock;
|
||||
|
||||
use function max;
|
||||
|
||||
final readonly class VisibiliteNotesPolicy
|
||||
{
|
||||
private const int DELAI_PARENTS_HEURES = 24;
|
||||
public const int DELAI_PARENTS_HEURES_DEFAUT = 24;
|
||||
|
||||
public function __construct(
|
||||
private Clock $clock,
|
||||
@@ -21,13 +23,14 @@ final readonly class VisibiliteNotesPolicy
|
||||
return $evaluation->notesPubliees();
|
||||
}
|
||||
|
||||
public function visiblePourParent(Evaluation $evaluation): bool
|
||||
public function visiblePourParent(Evaluation $evaluation, int $delaiHeures = self::DELAI_PARENTS_HEURES_DEFAUT): bool
|
||||
{
|
||||
if (!$evaluation->notesPubliees()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$delai = $evaluation->gradesPublishedAt?->modify('+' . self::DELAI_PARENTS_HEURES . ' hours');
|
||||
$delaiHeures = max(0, $delaiHeures);
|
||||
$delai = $evaluation->gradesPublishedAt?->modify('+' . $delaiHeures . ' hours');
|
||||
|
||||
return $delai !== null && $delai <= $this->clock->now();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\GradeNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||
@@ -32,4 +33,7 @@ interface GradeRepository
|
||||
public function findByEvaluations(array $evaluationIds, TenantId $tenantId): array;
|
||||
|
||||
public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool;
|
||||
|
||||
/** @return array<Grade> */
|
||||
public function findByStudent(UserId $studentId, TenantId $tenantId): array;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\ChildGradesDto;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\ChildGradesSummaryDto;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesHandler;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesQuery;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryHandler;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryQuery;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\ParentGradeDto;
|
||||
use App\Scolarite\Infrastructure\Security\GradeParentVoter;
|
||||
|
||||
use function array_map;
|
||||
use function is_string;
|
||||
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Endpoints de consultation des notes des enfants pour le parent connecté.
|
||||
*/
|
||||
#[IsGranted(GradeParentVoter::VIEW)]
|
||||
final readonly class ParentGradeController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private GetChildrenGradesHandler $gradesHandler,
|
||||
private GetChildrenGradesSummaryHandler $summaryHandler,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes d'un enfant spécifique.
|
||||
*/
|
||||
#[Route('/api/me/children/{childId}/grades', name: 'api_parent_child_grades', methods: ['GET'])]
|
||||
public function childGrades(string $childId): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
|
||||
$children = ($this->gradesHandler)(new GetChildrenGradesQuery(
|
||||
parentId: $user->userId(),
|
||||
tenantId: $user->tenantId(),
|
||||
childId: $childId,
|
||||
));
|
||||
|
||||
if ($children === []) {
|
||||
throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.');
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => $this->serializeChild($children[0]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes d'un enfant filtrées par matière.
|
||||
*/
|
||||
#[Route('/api/me/children/{childId}/grades/subject/{subjectId}', name: 'api_parent_child_grades_by_subject', methods: ['GET'])]
|
||||
public function childGradesBySubject(string $childId, string $subjectId): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
|
||||
$children = ($this->gradesHandler)(new GetChildrenGradesQuery(
|
||||
parentId: $user->userId(),
|
||||
tenantId: $user->tenantId(),
|
||||
childId: $childId,
|
||||
subjectId: $subjectId,
|
||||
));
|
||||
|
||||
if ($children === []) {
|
||||
throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.');
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => $this->serializeChild($children[0]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résumé des moyennes de tous les enfants.
|
||||
*/
|
||||
#[Route('/api/me/children/grades/summary', name: 'api_parent_children_grades_summary', methods: ['GET'])]
|
||||
public function gradesSummary(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
|
||||
$periodId = $request->query->get('periodId');
|
||||
|
||||
$summaries = ($this->summaryHandler)(new GetChildrenGradesSummaryQuery(
|
||||
parentId: $user->userId(),
|
||||
tenantId: $user->tenantId(),
|
||||
periodId: is_string($periodId) && $periodId !== '' ? $periodId : null,
|
||||
));
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => array_map($this->serializeSummary(...), $summaries),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getSecurityUser(): SecurityUser
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new AccessDeniedHttpException('Authentification requise.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeChild(ChildGradesDto $child): array
|
||||
{
|
||||
return [
|
||||
'childId' => $child->childId,
|
||||
'firstName' => $child->firstName,
|
||||
'lastName' => $child->lastName,
|
||||
'grades' => array_map($this->serializeGrade(...), $child->grades),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeGrade(ParentGradeDto $grade): array
|
||||
{
|
||||
return [
|
||||
'id' => $grade->id,
|
||||
'evaluationId' => $grade->evaluationId,
|
||||
'evaluationTitle' => $grade->evaluationTitle,
|
||||
'evaluationDate' => $grade->evaluationDate,
|
||||
'gradeScale' => $grade->gradeScale,
|
||||
'coefficient' => $grade->coefficient,
|
||||
'subjectId' => $grade->subjectId,
|
||||
'subjectName' => $grade->subjectName,
|
||||
'subjectColor' => $grade->subjectColor,
|
||||
'value' => $grade->value,
|
||||
'status' => $grade->status,
|
||||
'appreciation' => $grade->appreciation,
|
||||
'publishedAt' => $grade->publishedAt,
|
||||
'classAverage' => $grade->classAverage,
|
||||
'classMin' => $grade->classMin,
|
||||
'classMax' => $grade->classMax,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeSummary(ChildGradesSummaryDto $summary): array
|
||||
{
|
||||
return [
|
||||
'childId' => $summary->childId,
|
||||
'firstName' => $summary->firstName,
|
||||
'lastName' => $summary->lastName,
|
||||
'periodId' => $summary->periodId,
|
||||
'subjectAverages' => $summary->subjectAverages,
|
||||
'generalAverage' => $summary->generalAverage,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -170,6 +170,23 @@ final readonly class DoctrineGradeRepository implements GradeRepository
|
||||
return (int) $countValue > 0;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByStudent(UserId $studentId, TenantId $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM grades
|
||||
WHERE student_id = :student_id
|
||||
AND tenant_id = :tenant_id
|
||||
ORDER BY created_at DESC',
|
||||
[
|
||||
'student_id' => (string) $studentId,
|
||||
'tenant_id' => (string) $tenantId,
|
||||
],
|
||||
);
|
||||
|
||||
return array_map($this->hydrate(...), $rows);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function hydrate(array $row): Grade
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\GradeNotFoundException;
|
||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||
@@ -98,4 +99,14 @@ final class InMemoryGradeRepository implements GradeRepository
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByStudent(UserId $studentId, TenantId $tenantId): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (Grade $g): bool => $g->studentId->equals($studentId)
|
||||
&& $g->tenantId->equals($tenantId),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Voter pour la consultation des notes des enfants par le parent.
|
||||
*
|
||||
* @extends Voter<string, null>
|
||||
*/
|
||||
final class GradeParentVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'GRADE_PARENT_VIEW';
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return $attribute === self::VIEW;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(Role::PARENT->value, $user->getRoles(), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Application\Port\ParentGradeDelayReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function is_numeric;
|
||||
|
||||
use Override;
|
||||
|
||||
final readonly class DatabaseParentGradeDelayReader implements ParentGradeDelayReader
|
||||
{
|
||||
private const int DEFAULT_DELAY_HOURS = 24;
|
||||
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delayHoursForTenant(TenantId $tenantId): int
|
||||
{
|
||||
$result = $this->connection->fetchOne(
|
||||
'SELECT parent_grade_delay_hours FROM school_grading_configurations WHERE tenant_id = :tenant_id LIMIT 1',
|
||||
['tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
if ($result === false || !is_numeric($result)) {
|
||||
return self::DEFAULT_DELAY_HOURS;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
<?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\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\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 ParentGradeEndpointsTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string PARENT_ID = '99990001-0001-0001-0001-000000000001';
|
||||
private const string STUDENT_ID = '99990001-0001-0001-0001-000000000002';
|
||||
private const string TEACHER_ID = '99990001-0001-0001-0001-000000000003';
|
||||
private const string CLASS_ID = '99990001-0001-0001-0001-000000000010';
|
||||
private const string SUBJECT_ID = '99990001-0001-0001-0001-000000000020';
|
||||
private const string SUBJECT2_ID = '99990001-0001-0001-0001-000000000021';
|
||||
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
|
||||
|
||||
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 AND class_id = :cid)', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]);
|
||||
$connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid AND evaluation_id IN (SELECT id FROM evaluations WHERE class_id = :cid))', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]);
|
||||
$connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid AND evaluation_id IN (SELECT id FROM evaluations WHERE class_id = :cid)', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]);
|
||||
$connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid AND class_id = :cid', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]);
|
||||
$connection->executeStatement('DELETE FROM student_guardians WHERE guardian_id = :gid', ['gid' => self::PARENT_ID]);
|
||||
$connection->executeStatement('DELETE FROM class_assignments WHERE user_id = :uid', ['uid' => self::STUDENT_ID]);
|
||||
$connection->executeStatement('DELETE FROM users WHERE id IN (:p, :s, :t)', ['p' => self::PARENT_ID, 's' => self::STUDENT_ID, 't' => self::TEACHER_ID]);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /api/me/children/{childId}/grades — Auth & Access
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesReturns403ForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesReturns404ForUnlinkedChild(): void
|
||||
{
|
||||
$unlinkedChildId = '99990001-0001-0001-0001-000000000099';
|
||||
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . $unlinkedChildId . '/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /api/me/children/{childId}/grades — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesReturnsGradesForLinkedChild(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array{data: array{childId: string, grades: list<array<string, mixed>>}} $json */
|
||||
$json = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertSame(self::STUDENT_ID, $json['data']['childId']);
|
||||
self::assertNotEmpty($json['data']['grades']);
|
||||
|
||||
$grade = $json['data']['grades'][0];
|
||||
self::assertArrayHasKey('evaluationTitle', $grade);
|
||||
self::assertArrayHasKey('value', $grade);
|
||||
self::assertArrayHasKey('status', $grade);
|
||||
self::assertArrayHasKey('classAverage', $grade);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /api/me/children/{childId}/grades/subject/{subjectId} — Auth & Access
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesBySubjectReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesBySubjectReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesBySubjectReturns403ForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesBySubjectReturns404ForUnlinkedChild(): void
|
||||
{
|
||||
$unlinkedChildId = '99990001-0001-0001-0001-000000000099';
|
||||
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . $unlinkedChildId . '/grades/subject/' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /api/me/children/{childId}/grades/subject/{subjectId} — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getChildGradesBySubjectFiltersCorrectly(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array{data: array{grades: list<array<string, mixed>>}} $json */
|
||||
$json = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
foreach ($json['data']['grades'] as $grade) {
|
||||
self::assertSame(self::SUBJECT_ID, $grade['subjectId']);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /api/me/children/grades/summary — Auth & Access
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getGradesSummaryReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/children/grades/summary', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getGradesSummaryReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/grades/summary', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /api/me/children/grades/summary — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getGradesSummaryReturnsAveragesForParent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/grades/summary', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array{data: list<array{childId: string, generalAverage: float|null, subjectAverages: list<mixed>}>} $json */
|
||||
$json = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertNotEmpty($json['data']);
|
||||
self::assertSame(self::STUDENT_ID, $json['data'][0]['childId']);
|
||||
self::assertNotNull($json['data'][0]['generalAverage']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getGradesSummaryAcceptsPeriodIdQueryParameter(): void
|
||||
{
|
||||
$periodId = '99990001-0001-0001-0001-000000000050';
|
||||
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/grades/summary?periodId=' . $periodId, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array{data: list<mixed>} $json */
|
||||
$json = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
// With a non-existent period, the response should still be 200 but with
|
||||
// empty or zero averages (no grades match). The key assertion is that the
|
||||
// endpoint accepts the parameter without error.
|
||||
self::assertIsArray($json['data']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getGradesSummaryReturns403ForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/children/grades/summary', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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-pg@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('2026-03-15 10:00:00');
|
||||
|
||||
$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, 'parent-pg@test.local', '', 'Marie', 'Dupont', '[\"ROLE_PARENT\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::PARENT_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-pg@test.local', '', 'Emma', '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, 'teacher-pg@test.local', '', 'Jean', 'Martin', '[\"ROLE_PROF\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
|
||||
// Link parent to student
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at)
|
||||
VALUES (gen_random_uuid(), :tid, :sid, :gid, 'mère', NOW())
|
||||
ON CONFLICT DO NOTHING",
|
||||
['tid' => self::TENANT_ID, 'sid' => self::STUDENT_ID, 'gid' => self::PARENT_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-PG-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, 'PG-Mathématiques', 'PGMATH', '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, 'PG-Français', 'PGFRA', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::SUBJECT2_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||
);
|
||||
|
||||
// Assign student to class
|
||||
$connection->executeStatement(
|
||||
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :uid, :cid, :ayid, NOW(), NOW(), NOW())
|
||||
ON CONFLICT (user_id, academic_year_id) DO NOTHING',
|
||||
['tid' => self::TENANT_ID, 'uid' => self::STUDENT_ID, 'cid' => self::CLASS_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);
|
||||
|
||||
// Published evaluation (well past 24h delay)
|
||||
$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 Maths PG',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(1.0),
|
||||
now: new DateTimeImmutable('2026-02-10'),
|
||||
);
|
||||
$eval1->publierNotes(new DateTimeImmutable('2026-02-16 10:00:00'));
|
||||
$eval1->pullDomainEvents();
|
||||
$evalRepo->save($eval1);
|
||||
|
||||
$grade1 = Grade::saisir(
|
||||
tenantId: $tenantId,
|
||||
evaluationId: $eval1->id,
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
value: new GradeValue(15.0),
|
||||
status: GradeStatus::GRADED,
|
||||
gradeScale: new GradeScale(20),
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: $now,
|
||||
);
|
||||
$grade1->pullDomainEvents();
|
||||
$gradeRepo->save($grade1);
|
||||
|
||||
$stats1 = $calculator->calculateClassStatistics([15.0, 12.0, 18.0]);
|
||||
$statsRepo->save($eval1->id, $stats1);
|
||||
|
||||
// Second evaluation, different subject
|
||||
$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 PG',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-03-01'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(2.0),
|
||||
now: new DateTimeImmutable('2026-02-25'),
|
||||
);
|
||||
$eval2->publierNotes(new DateTimeImmutable('2026-03-02 10:00:00'));
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\ParentChildrenReader;
|
||||
use App\Scolarite\Application\Port\ParentGradeDelayReader;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesHandler;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesQuery;
|
||||
use App\Scolarite\Domain\Model\Evaluation\ClassStatistics;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
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\Policy\VisibiliteNotesPolicy;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationStatisticsRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetChildrenGradesHandlerTest extends TestCase
|
||||
{
|
||||
use ParentGradeTestHelper;
|
||||
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string PARENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||
private const string CHILD_A_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string CHILD_B_ID = '550e8400-e29b-41d4-a716-446655440051';
|
||||
private const string CLASS_A_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string CLASS_B_ID = '550e8400-e29b-41d4-a716-446655440021';
|
||||
private const string SUBJECT_MATH_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string SUBJECT_FRENCH_ID = '550e8400-e29b-41d4-a716-446655440031';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
|
||||
private InMemoryEvaluationRepository $evaluationRepository;
|
||||
private InMemoryGradeRepository $gradeRepository;
|
||||
private InMemoryEvaluationStatisticsRepository $statisticsRepository;
|
||||
private DateTimeImmutable $now;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->evaluationRepository = new InMemoryEvaluationRepository();
|
||||
$this->gradeRepository = new InMemoryGradeRepository();
|
||||
$this->statisticsRepository = new InMemoryEvaluationStatisticsRepository();
|
||||
$this->now = new DateTimeImmutable('2026-04-06 14:00:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenParentHasNoChildren(): void
|
||||
{
|
||||
$handler = $this->createHandler(children: []);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsGradesForSingleChild(): void
|
||||
{
|
||||
$evaluation = $this->givenPublishedEvaluation(
|
||||
title: 'Contrôle chapitre 5',
|
||||
publishedAt: '2026-04-04 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evaluation, self::CHILD_A_ID, 15.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame(self::CHILD_A_ID, $result[0]->childId);
|
||||
self::assertSame('Emma', $result[0]->firstName);
|
||||
self::assertSame('Dupont', $result[0]->lastName);
|
||||
self::assertCount(1, $result[0]->grades);
|
||||
self::assertSame(15.0, $result[0]->grades[0]->value);
|
||||
self::assertSame('Contrôle chapitre 5', $result[0]->grades[0]->evaluationTitle);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersOutGradesWithinDelayPeriod(): void
|
||||
{
|
||||
// Published 12h ago — within the 24h delay
|
||||
$evaluation = $this->givenPublishedEvaluation(
|
||||
title: 'Récent',
|
||||
publishedAt: '2026-04-06 02:00:00',
|
||||
);
|
||||
$this->givenGrade($evaluation, self::CHILD_A_ID, 10.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame([], $result[0]->grades);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIncludesGradesPastDelayPeriod(): void
|
||||
{
|
||||
$evaluation = $this->givenPublishedEvaluation(
|
||||
title: 'Ancien',
|
||||
publishedAt: '2026-04-04 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evaluation, self::CHILD_A_ID, 12.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertCount(1, $result[0]->grades);
|
||||
self::assertSame(12.0, $result[0]->grades[0]->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsGradesForMultipleChildren(): void
|
||||
{
|
||||
$evalA = $this->givenPublishedEvaluation(
|
||||
title: 'Maths 6A',
|
||||
classId: self::CLASS_A_ID,
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evalA, self::CHILD_A_ID, 14.0);
|
||||
|
||||
$evalB = $this->givenPublishedEvaluation(
|
||||
title: 'Maths 6B',
|
||||
classId: self::CLASS_B_ID,
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evalB, self::CHILD_B_ID, 16.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('Emma', $result[0]->firstName);
|
||||
self::assertCount(1, $result[0]->grades);
|
||||
self::assertSame(14.0, $result[0]->grades[0]->value);
|
||||
self::assertSame('Lucas', $result[1]->firstName);
|
||||
self::assertCount(1, $result[1]->grades);
|
||||
self::assertSame(16.0, $result[1]->grades[0]->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersToSpecificChild(): void
|
||||
{
|
||||
$evalA = $this->givenPublishedEvaluation(
|
||||
title: 'Maths 6A',
|
||||
classId: self::CLASS_A_ID,
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evalA, self::CHILD_A_ID, 14.0);
|
||||
|
||||
$evalB = $this->givenPublishedEvaluation(
|
||||
title: 'Maths 6B',
|
||||
classId: self::CLASS_B_ID,
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evalB, self::CHILD_B_ID, 16.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
childId: self::CHILD_B_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('Lucas', $result[0]->firstName);
|
||||
self::assertCount(1, $result[0]->grades);
|
||||
self::assertSame(16.0, $result[0]->grades[0]->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyGradesWhenChildHasNoGrades(): void
|
||||
{
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame([], $result[0]->grades);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIncludesClassStatistics(): void
|
||||
{
|
||||
$evaluation = $this->givenPublishedEvaluation(
|
||||
title: 'Stats test',
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evaluation, self::CHILD_A_ID, 14.0);
|
||||
$this->statisticsRepository->save(
|
||||
$evaluation->id,
|
||||
new ClassStatistics(average: 12.5, min: 6.0, max: 18.0, median: 13.0, gradedCount: 25),
|
||||
);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result[0]->grades);
|
||||
self::assertSame(12.5, $result[0]->grades[0]->classAverage);
|
||||
self::assertSame(6.0, $result[0]->grades[0]->classMin);
|
||||
self::assertSame(18.0, $result[0]->grades[0]->classMax);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersBySubject(): void
|
||||
{
|
||||
$evalMath = $this->givenPublishedEvaluation(
|
||||
title: 'Maths',
|
||||
subjectId: self::SUBJECT_MATH_ID,
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evalMath, self::CHILD_A_ID, 15.0);
|
||||
|
||||
$evalFrench = $this->givenPublishedEvaluation(
|
||||
title: 'Français',
|
||||
subjectId: self::SUBJECT_FRENCH_ID,
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evalFrench, self::CHILD_A_ID, 12.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
subjectId: self::SUBJECT_MATH_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertCount(1, $result[0]->grades);
|
||||
self::assertSame(15.0, $result[0]->grades[0]->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFiltersOutUnpublishedEvaluations(): void
|
||||
{
|
||||
$unpublished = Evaluation::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString(self::CLASS_A_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_MATH_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'Non publié',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-04-01'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(1.0),
|
||||
now: new DateTimeImmutable('2026-03-25'),
|
||||
);
|
||||
$this->evaluationRepository->save($unpublished);
|
||||
// Grade exists but evaluation not published → should not appear
|
||||
$this->givenGrade($unpublished, self::CHILD_A_ID, 10.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame([], $result[0]->grades);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSortsGradesByEvaluationDateDescending(): void
|
||||
{
|
||||
$evalOld = $this->givenPublishedEvaluation(
|
||||
title: 'Ancien',
|
||||
publishedAt: '2026-04-01 10:00:00',
|
||||
evaluationDate: '2026-03-20',
|
||||
);
|
||||
$this->givenGrade($evalOld, self::CHILD_A_ID, 10.0);
|
||||
|
||||
$evalNew = $this->givenPublishedEvaluation(
|
||||
title: 'Récent',
|
||||
publishedAt: '2026-04-02 10:00:00',
|
||||
evaluationDate: '2026-04-01',
|
||||
);
|
||||
$this->givenGrade($evalNew, self::CHILD_A_ID, 16.0);
|
||||
|
||||
$evalMid = $this->givenPublishedEvaluation(
|
||||
title: 'Milieu',
|
||||
publishedAt: '2026-04-01 12:00:00',
|
||||
evaluationDate: '2026-03-25',
|
||||
);
|
||||
$this->givenGrade($evalMid, self::CHILD_A_ID, 13.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
$titles = array_map(static fn ($g) => $g->evaluationTitle, $result[0]->grades);
|
||||
self::assertSame(['Récent', 'Milieu', 'Ancien'], $titles);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUsesConfigurableDelayOf0HoursForImmediateVisibility(): void
|
||||
{
|
||||
$evaluation = $this->givenPublishedEvaluation(
|
||||
title: 'Immédiat',
|
||||
publishedAt: '2026-04-06 13:00:00',
|
||||
);
|
||||
$this->givenGrade($evaluation, self::CHILD_A_ID, 18.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
delayHours: 0,
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertCount(1, $result[0]->grades);
|
||||
self::assertSame(18.0, $result[0]->grades[0]->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIncludesAbsentAndDispensedGrades(): void
|
||||
{
|
||||
$evaluation = $this->givenPublishedEvaluation(
|
||||
title: 'Contrôle mixte',
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
|
||||
// Absent grade (no value)
|
||||
$absentGrade = Grade::saisir(
|
||||
tenantId: $evaluation->tenantId,
|
||||
evaluationId: $evaluation->id,
|
||||
studentId: UserId::fromString(self::CHILD_A_ID),
|
||||
value: null,
|
||||
status: GradeStatus::ABSENT,
|
||||
gradeScale: $evaluation->gradeScale,
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: new DateTimeImmutable('2026-03-26 10:00:00'),
|
||||
);
|
||||
$this->gradeRepository->save($absentGrade);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertCount(1, $result[0]->grades);
|
||||
self::assertNull($result[0]->grades[0]->value);
|
||||
self::assertSame('absent', $result[0]->grades[0]->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUsesConfigurableDelayOf48Hours(): void
|
||||
{
|
||||
$evaluation = $this->givenPublishedEvaluation(
|
||||
title: 'Lent',
|
||||
publishedAt: '2026-04-05 08:00:00',
|
||||
);
|
||||
$this->givenGrade($evaluation, self::CHILD_A_ID, 11.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
delayHours: 48,
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame([], $result[0]->grades);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{studentId: string, firstName: string, lastName: string}> $children
|
||||
*/
|
||||
private function createHandler(
|
||||
array $children = [],
|
||||
int $delayHours = 24,
|
||||
): GetChildrenGradesHandler {
|
||||
$parentChildrenReader = new class($children) implements ParentChildrenReader {
|
||||
/** @param array<array{studentId: string, firstName: string, lastName: string}> $children */
|
||||
public function __construct(private readonly array $children)
|
||||
{
|
||||
}
|
||||
|
||||
public function childrenOf(string $guardianId, TenantId $tenantId): array
|
||||
{
|
||||
return $this->children;
|
||||
}
|
||||
};
|
||||
|
||||
$displayReader = new class implements ScheduleDisplayReader {
|
||||
public function subjectDisplay(string $tenantId, string ...$subjectIds): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($subjectIds as $id) {
|
||||
$map[$id] = ['name' => 'Mathématiques', 'color' => '#3b82f6'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($teacherIds as $id) {
|
||||
$map[$id] = 'Jean Dupont';
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class($this->now) implements Clock {
|
||||
public function __construct(private readonly DateTimeImmutable $now)
|
||||
{
|
||||
}
|
||||
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
$policy = new VisibiliteNotesPolicy($clock);
|
||||
|
||||
$delayReader = new class($delayHours) implements ParentGradeDelayReader {
|
||||
public function __construct(private readonly int $hours)
|
||||
{
|
||||
}
|
||||
|
||||
public function delayHoursForTenant(TenantId $tenantId): int
|
||||
{
|
||||
return $this->hours;
|
||||
}
|
||||
};
|
||||
|
||||
return new GetChildrenGradesHandler(
|
||||
$parentChildrenReader,
|
||||
$this->evaluationRepository,
|
||||
$this->gradeRepository,
|
||||
$this->statisticsRepository,
|
||||
$displayReader,
|
||||
$policy,
|
||||
$delayReader,
|
||||
);
|
||||
}
|
||||
|
||||
protected function evaluationRepository(): InMemoryEvaluationRepository
|
||||
{
|
||||
return $this->evaluationRepository;
|
||||
}
|
||||
|
||||
protected function gradeRepository(): InMemoryGradeRepository
|
||||
{
|
||||
return $this->gradeRepository;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
use App\Scolarite\Application\Port\ParentChildrenReader;
|
||||
use App\Scolarite\Application\Port\ParentGradeDelayReader;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesHandler;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryHandler;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryQuery;
|
||||
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||
use App\Scolarite\Domain\Policy\VisibiliteNotesPolicy;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationStatisticsRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetChildrenGradesSummaryHandlerTest extends TestCase
|
||||
{
|
||||
use ParentGradeTestHelper;
|
||||
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string PARENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||
private const string CHILD_A_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string CHILD_B_ID = '550e8400-e29b-41d4-a716-446655440051';
|
||||
private const string CLASS_A_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_MATH_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string SUBJECT_FRENCH_ID = '550e8400-e29b-41d4-a716-446655440031';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
|
||||
private InMemoryEvaluationRepository $evaluationRepository;
|
||||
private InMemoryGradeRepository $gradeRepository;
|
||||
private InMemoryEvaluationStatisticsRepository $statisticsRepository;
|
||||
private DateTimeImmutable $now;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->evaluationRepository = new InMemoryEvaluationRepository();
|
||||
$this->gradeRepository = new InMemoryGradeRepository();
|
||||
$this->statisticsRepository = new InMemoryEvaluationStatisticsRepository();
|
||||
$this->now = new DateTimeImmutable('2026-04-06 14:00:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenParentHasNoChildren(): void
|
||||
{
|
||||
$handler = $this->createHandler(children: []);
|
||||
|
||||
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itComputesAveragesFromVisibleGrades(): void
|
||||
{
|
||||
// Maths: 16/20 coeff 2 + 12/20 coeff 1 → weighted = (32+12)/3 = 14.67
|
||||
$eval1 = $this->givenPublishedEvaluation(
|
||||
title: 'DS Maths',
|
||||
subjectId: self::SUBJECT_MATH_ID,
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
coefficient: 2.0,
|
||||
);
|
||||
$this->givenGrade($eval1, self::CHILD_A_ID, 16.0);
|
||||
|
||||
$eval2 = $this->givenPublishedEvaluation(
|
||||
title: 'Contrôle Maths',
|
||||
subjectId: self::SUBJECT_MATH_ID,
|
||||
publishedAt: '2026-04-03 12:00:00',
|
||||
coefficient: 1.0,
|
||||
);
|
||||
$this->givenGrade($eval2, self::CHILD_A_ID, 12.0);
|
||||
|
||||
// Français: 15/20 coeff 1 → 15.0
|
||||
$eval3 = $this->givenPublishedEvaluation(
|
||||
title: 'Dictée',
|
||||
subjectId: self::SUBJECT_FRENCH_ID,
|
||||
publishedAt: '2026-04-03 14:00:00',
|
||||
coefficient: 1.0,
|
||||
);
|
||||
$this->givenGrade($eval3, self::CHILD_A_ID, 15.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame(self::CHILD_A_ID, $result[0]->childId);
|
||||
self::assertCount(2, $result[0]->subjectAverages);
|
||||
|
||||
// General = mean of subject averages = (14.67 + 15.0) / 2 = 14.84
|
||||
self::assertNotNull($result[0]->generalAverage);
|
||||
self::assertEqualsWithDelta(14.84, $result[0]->generalAverage, 0.01);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRespectsDelayAndExcludesRecentGradesFromAverages(): void
|
||||
{
|
||||
// Visible grade (48h ago)
|
||||
$evalOld = $this->givenPublishedEvaluation(
|
||||
title: 'Ancien',
|
||||
subjectId: self::SUBJECT_MATH_ID,
|
||||
publishedAt: '2026-04-04 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evalOld, self::CHILD_A_ID, 10.0);
|
||||
|
||||
// Not yet visible (12h ago, within 24h delay)
|
||||
$evalRecent = $this->givenPublishedEvaluation(
|
||||
title: 'Récent',
|
||||
subjectId: self::SUBJECT_MATH_ID,
|
||||
publishedAt: '2026-04-06 02:00:00',
|
||||
);
|
||||
$this->givenGrade($evalRecent, self::CHILD_A_ID, 20.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
// Only the old grade (10.0) should be in the average, not the recent one (20.0)
|
||||
self::assertSame(10.0, $result[0]->generalAverage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullAverageWhenNoVisibleGrades(): void
|
||||
{
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame([], $result[0]->subjectAverages);
|
||||
self::assertNull($result[0]->generalAverage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullAverageWhenAllGradesHaveNullValue(): void
|
||||
{
|
||||
// Grades with null value (status ABSENT / DISPENSED) should not contribute to averages
|
||||
$eval1 = $this->givenPublishedEvaluation(
|
||||
title: 'DS Maths',
|
||||
subjectId: self::SUBJECT_MATH_ID,
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGradeWithStatus($eval1, self::CHILD_A_ID, GradeStatus::ABSENT);
|
||||
|
||||
$eval2 = $this->givenPublishedEvaluation(
|
||||
title: 'Dictee',
|
||||
subjectId: self::SUBJECT_FRENCH_ID,
|
||||
publishedAt: '2026-04-03 12:00:00',
|
||||
);
|
||||
$this->givenGradeWithStatus($eval2, self::CHILD_A_ID, GradeStatus::DISPENSED);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame([], $result[0]->subjectAverages);
|
||||
self::assertNull($result[0]->generalAverage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsAveragesForMultipleChildren(): void
|
||||
{
|
||||
$evalA = $this->givenPublishedEvaluation(
|
||||
title: 'Maths A',
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evalA, self::CHILD_A_ID, 16.0);
|
||||
|
||||
$evalB = $this->givenPublishedEvaluation(
|
||||
title: 'Maths B',
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
publishedAt: '2026-04-03 10:00:00',
|
||||
);
|
||||
$this->givenGrade($evalB, self::CHILD_B_ID, 8.0);
|
||||
|
||||
$handler = $this->createHandler(
|
||||
children: [
|
||||
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||
['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||
parentId: self::PARENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame(16.0, $result[0]->generalAverage);
|
||||
self::assertSame(8.0, $result[1]->generalAverage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{studentId: string, firstName: string, lastName: string}> $children
|
||||
*/
|
||||
private function createHandler(
|
||||
array $children = [],
|
||||
): GetChildrenGradesSummaryHandler {
|
||||
$parentChildrenReader = new class($children) implements ParentChildrenReader {
|
||||
/** @param array<array{studentId: string, firstName: string, lastName: string}> $children */
|
||||
public function __construct(private readonly array $children)
|
||||
{
|
||||
}
|
||||
|
||||
public function childrenOf(string $guardianId, TenantId $tenantId): array
|
||||
{
|
||||
return $this->children;
|
||||
}
|
||||
};
|
||||
|
||||
$displayReader = new class implements ScheduleDisplayReader {
|
||||
public function subjectDisplay(string $tenantId, string ...$subjectIds): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($subjectIds as $id) {
|
||||
$map[$id] = ['name' => 'Mathématiques', 'color' => '#3b82f6'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class($this->now) implements Clock {
|
||||
public function __construct(private readonly DateTimeImmutable $now)
|
||||
{
|
||||
}
|
||||
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
$delayReader = new class implements ParentGradeDelayReader {
|
||||
public function delayHoursForTenant(TenantId $tenantId): int
|
||||
{
|
||||
return 24;
|
||||
}
|
||||
};
|
||||
|
||||
$gradesHandler = new GetChildrenGradesHandler(
|
||||
$parentChildrenReader,
|
||||
$this->evaluationRepository,
|
||||
$this->gradeRepository,
|
||||
$this->statisticsRepository,
|
||||
$displayReader,
|
||||
new VisibiliteNotesPolicy($clock),
|
||||
$delayReader,
|
||||
);
|
||||
|
||||
return new GetChildrenGradesSummaryHandler($gradesHandler);
|
||||
}
|
||||
|
||||
protected function evaluationRepository(): InMemoryEvaluationRepository
|
||||
{
|
||||
return $this->evaluationRepository;
|
||||
}
|
||||
|
||||
protected function gradeRepository(): InMemoryGradeRepository
|
||||
{
|
||||
return $this->gradeRepository;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
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\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
trait ParentGradeTestHelper
|
||||
{
|
||||
abstract protected function evaluationRepository(): InMemoryEvaluationRepository;
|
||||
|
||||
abstract protected function gradeRepository(): InMemoryGradeRepository;
|
||||
|
||||
private function givenPublishedEvaluation(
|
||||
string $title,
|
||||
string $publishedAt,
|
||||
string $classId = self::CLASS_A_ID,
|
||||
string $subjectId = self::SUBJECT_MATH_ID,
|
||||
string $evaluationDate = '2026-04-01',
|
||||
float $coefficient = 1.0,
|
||||
): Evaluation {
|
||||
$evaluation = Evaluation::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString($classId),
|
||||
subjectId: SubjectId::fromString($subjectId),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: $title,
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable($evaluationDate),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient($coefficient),
|
||||
now: new DateTimeImmutable('2026-03-25 08:00:00'),
|
||||
);
|
||||
|
||||
$evaluation->publierNotes(new DateTimeImmutable($publishedAt));
|
||||
$this->evaluationRepository()->save($evaluation);
|
||||
|
||||
return $evaluation;
|
||||
}
|
||||
|
||||
private function givenGrade(
|
||||
Evaluation $evaluation,
|
||||
string $studentId,
|
||||
float $value,
|
||||
): Grade {
|
||||
$grade = Grade::saisir(
|
||||
tenantId: $evaluation->tenantId,
|
||||
evaluationId: $evaluation->id,
|
||||
studentId: UserId::fromString($studentId),
|
||||
value: new GradeValue($value),
|
||||
status: GradeStatus::GRADED,
|
||||
gradeScale: $evaluation->gradeScale,
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: new DateTimeImmutable('2026-03-26 10:00:00'),
|
||||
);
|
||||
|
||||
$this->gradeRepository()->save($grade);
|
||||
|
||||
return $grade;
|
||||
}
|
||||
|
||||
private function givenGradeWithStatus(
|
||||
Evaluation $evaluation,
|
||||
string $studentId,
|
||||
GradeStatus $status,
|
||||
): Grade {
|
||||
$grade = Grade::saisir(
|
||||
tenantId: $evaluation->tenantId,
|
||||
evaluationId: $evaluation->id,
|
||||
studentId: UserId::fromString($studentId),
|
||||
value: null,
|
||||
status: $status,
|
||||
gradeScale: $evaluation->gradeScale,
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: new DateTimeImmutable('2026-03-26 10:00:00'),
|
||||
);
|
||||
|
||||
$this->gradeRepository()->save($grade);
|
||||
|
||||
return $grade;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,50 @@ final class VisibiliteNotesPolicyTest extends TestCase
|
||||
self::assertFalse($policy->visiblePourParent($evaluation));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parentVoitImmediatementAvecDelaiZero(): void
|
||||
{
|
||||
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||
$now = new DateTimeImmutable('2026-03-27 14:00:01'); // 1 seconde après
|
||||
$policy = $this->createPolicy($now);
|
||||
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||
|
||||
self::assertTrue($policy->visiblePourParent($evaluation, delaiHeures: 0));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parentNeVoitPasAvec48hDeDelai(): void
|
||||
{
|
||||
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||
$now = new DateTimeImmutable('2026-03-29 13:59:59'); // 47h59 après
|
||||
$policy = $this->createPolicy($now);
|
||||
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||
|
||||
self::assertFalse($policy->visiblePourParent($evaluation, delaiHeures: 48));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parentVoitApres48hDeDelai(): void
|
||||
{
|
||||
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||
$now = new DateTimeImmutable('2026-03-29 14:00:00'); // exactement 48h après
|
||||
$policy = $this->createPolicy($now);
|
||||
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||
|
||||
self::assertTrue($policy->visiblePourParent($evaluation, delaiHeures: 48));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parentVoitImmediatementAvecDelaiNegatifTraiteCommeZero(): void
|
||||
{
|
||||
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||
$now = new DateTimeImmutable('2026-03-27 14:00:01');
|
||||
$policy = $this->createPolicy($now);
|
||||
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||
|
||||
self::assertTrue($policy->visiblePourParent($evaluation, delaiHeures: -5));
|
||||
}
|
||||
|
||||
private function createPolicy(DateTimeImmutable $now): VisibiliteNotesPolicy
|
||||
{
|
||||
$clock = new class($now) implements Clock {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\PeriodInfo;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\StudentMyAveragesProvider;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\StudentMyAveragesResource;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryStudentAverageRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
final class StudentMyAveragesProviderTest extends TestCase
|
||||
{
|
||||
private const string TENANT_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string STUDENT_UUID = '22222222-2222-2222-2222-222222222222';
|
||||
private const string SUBJECT_UUID = '66666666-6666-6666-6666-666666666666';
|
||||
private const string PERIOD_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
private InMemoryStudentAverageRepository $averageRepository;
|
||||
private TenantContext $tenantContext;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->averageRepository = new InMemoryStudentAverageRepository();
|
||||
$this->tenantContext = new TenantContext();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Auth & Tenant Guards
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function itRejects401WhenNoTenant(): void
|
||||
{
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejects401WhenNoUser(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$provider = $this->createProvider(
|
||||
user: null,
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejects403ForTeacher(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$provider = $this->createProvider(
|
||||
user: $this->teacherUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejects403ForParent(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$provider = $this->createProvider(
|
||||
user: $this->parentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejects403ForAdmin(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$provider = $this->createProvider(
|
||||
user: $this->adminUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Period auto-detection
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function itAutoDetectsCurrentPeriodWhenNoPeriodIdInFilters(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: new PeriodInfo(self::PERIOD_ID, new DateTimeImmutable('2026-01-01'), new DateTimeImmutable('2026-03-31')),
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get());
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertSame(self::PERIOD_ID, $result->periodId);
|
||||
self::assertNotEmpty($result->subjectAverages);
|
||||
self::assertSame(16.0, $result->generalAverage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyResourceWhenNoPeriodDetected(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get());
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertNull($result->periodId);
|
||||
self::assertEmpty($result->subjectAverages);
|
||||
self::assertNull($result->generalAverage);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Explicit periodId from filters
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function itUsesExplicitPeriodIdFromFilters(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), [], [
|
||||
'filters' => ['periodId' => self::PERIOD_ID],
|
||||
]);
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertSame(self::PERIOD_ID, $result->periodId);
|
||||
self::assertNotEmpty($result->subjectAverages);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptySubjectAveragesForUnknownPeriod(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$unknownPeriod = '99999999-9999-9999-9999-999999999999';
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), [], [
|
||||
'filters' => ['periodId' => $unknownPeriod],
|
||||
]);
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertSame($unknownPeriod, $result->periodId);
|
||||
self::assertEmpty($result->subjectAverages);
|
||||
self::assertNull($result->generalAverage);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Response shape
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function itReturnsStudentIdInResource(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), [], [
|
||||
'filters' => ['periodId' => self::PERIOD_ID],
|
||||
]);
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertSame(self::STUDENT_UUID, $result->studentId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsSubjectAverageShape(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), [], [
|
||||
'filters' => ['periodId' => self::PERIOD_ID],
|
||||
]);
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertCount(1, $result->subjectAverages);
|
||||
|
||||
$avg = $result->subjectAverages[0];
|
||||
self::assertSame(self::SUBJECT_UUID, $avg['subjectId']);
|
||||
self::assertSame(16.0, $avg['average']);
|
||||
self::assertSame(1, $avg['gradeCount']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private function setTenant(): void
|
||||
{
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
));
|
||||
}
|
||||
|
||||
private function seedAverages(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_UUID);
|
||||
$studentId = UserId::fromString(self::STUDENT_UUID);
|
||||
|
||||
$this->averageRepository->saveSubjectAverage(
|
||||
$tenantId,
|
||||
$studentId,
|
||||
SubjectId::fromString(self::SUBJECT_UUID),
|
||||
self::PERIOD_ID,
|
||||
16.0,
|
||||
1,
|
||||
);
|
||||
|
||||
$this->averageRepository->saveGeneralAverage(
|
||||
$tenantId,
|
||||
$studentId,
|
||||
self::PERIOD_ID,
|
||||
16.0,
|
||||
);
|
||||
}
|
||||
|
||||
private function createProvider(?SecurityUser $user, ?PeriodInfo $periodForDate): StudentMyAveragesProvider
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
$periodFinder = new class($periodForDate) implements PeriodFinder {
|
||||
public function __construct(private readonly ?PeriodInfo $info)
|
||||
{
|
||||
}
|
||||
|
||||
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
||||
{
|
||||
return $this->info;
|
||||
}
|
||||
};
|
||||
|
||||
return new StudentMyAveragesProvider(
|
||||
$this->averageRepository,
|
||||
$periodFinder,
|
||||
$this->tenantContext,
|
||||
$security,
|
||||
);
|
||||
}
|
||||
|
||||
private function studentUser(): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: UserId::fromString(self::STUDENT_UUID),
|
||||
email: 'student@test.local',
|
||||
hashedPassword: '',
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
roles: ['ROLE_ELEVE'],
|
||||
);
|
||||
}
|
||||
|
||||
private function teacherUser(): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: UserId::fromString('44444444-4444-4444-4444-444444444444'),
|
||||
email: 'teacher@test.local',
|
||||
hashedPassword: '',
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
roles: ['ROLE_PROF'],
|
||||
);
|
||||
}
|
||||
|
||||
private function parentUser(): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: UserId::fromString('88888888-8888-8888-8888-888888888888'),
|
||||
email: 'parent@test.local',
|
||||
hashedPassword: '',
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
roles: ['ROLE_PARENT'],
|
||||
);
|
||||
}
|
||||
|
||||
private function adminUser(): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: UserId::fromString('33333333-3333-3333-3333-333333333333'),
|
||||
email: 'admin@test.local',
|
||||
hashedPassword: '',
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
roles: ['ROLE_ADMIN'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
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\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class InMemoryGradeRepositoryTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EVALUATION_A_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string EVALUATION_B_ID = '550e8400-e29b-41d4-a716-446655440011';
|
||||
private const string EVALUATION_C_ID = '550e8400-e29b-41d4-a716-446655440012';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
|
||||
private InMemoryGradeRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryGradeRepository();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByEvaluationsReturnsGroupedResults(): void
|
||||
{
|
||||
$gradeA1 = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0);
|
||||
$gradeA2 = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 16.0);
|
||||
$gradeB1 = $this->createGrade(self::EVALUATION_B_ID, self::TENANT_ID, 12.0);
|
||||
|
||||
$this->repository->save($gradeA1);
|
||||
$this->repository->save($gradeA2);
|
||||
$this->repository->save($gradeB1);
|
||||
|
||||
$result = $this->repository->findByEvaluations(
|
||||
[
|
||||
EvaluationId::fromString(self::EVALUATION_A_ID),
|
||||
EvaluationId::fromString(self::EVALUATION_B_ID),
|
||||
],
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertArrayHasKey(self::EVALUATION_A_ID, $result);
|
||||
self::assertArrayHasKey(self::EVALUATION_B_ID, $result);
|
||||
self::assertCount(2, $result[self::EVALUATION_A_ID]);
|
||||
self::assertCount(1, $result[self::EVALUATION_B_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByEvaluationsReturnsEmptyArrayWhenNoEvaluationIds(): void
|
||||
{
|
||||
$grade = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0);
|
||||
$this->repository->save($grade);
|
||||
|
||||
$result = $this->repository->findByEvaluations(
|
||||
[],
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByEvaluationsExcludesGradesFromDifferentTenant(): void
|
||||
{
|
||||
$gradeOwnTenant = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0);
|
||||
$gradeOtherTenant = $this->createGrade(self::EVALUATION_A_ID, self::OTHER_TENANT_ID, 18.0);
|
||||
|
||||
$this->repository->save($gradeOwnTenant);
|
||||
$this->repository->save($gradeOtherTenant);
|
||||
|
||||
$result = $this->repository->findByEvaluations(
|
||||
[EvaluationId::fromString(self::EVALUATION_A_ID)],
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertArrayHasKey(self::EVALUATION_A_ID, $result);
|
||||
self::assertCount(1, $result[self::EVALUATION_A_ID]);
|
||||
self::assertSame($gradeOwnTenant, $result[self::EVALUATION_A_ID][0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByEvaluationsExcludesGradesForUnrequestedEvaluations(): void
|
||||
{
|
||||
$gradeA = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0);
|
||||
$gradeC = $this->createGrade(self::EVALUATION_C_ID, self::TENANT_ID, 10.0);
|
||||
|
||||
$this->repository->save($gradeA);
|
||||
$this->repository->save($gradeC);
|
||||
|
||||
$result = $this->repository->findByEvaluations(
|
||||
[EvaluationId::fromString(self::EVALUATION_A_ID)],
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertArrayHasKey(self::EVALUATION_A_ID, $result);
|
||||
self::assertArrayNotHasKey(self::EVALUATION_C_ID, $result);
|
||||
}
|
||||
|
||||
private function createGrade(
|
||||
string $evaluationId,
|
||||
string $tenantId,
|
||||
float $value,
|
||||
): Grade {
|
||||
return Grade::saisir(
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
evaluationId: EvaluationId::fromString($evaluationId),
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
value: new GradeValue($value),
|
||||
status: GradeStatus::GRADED,
|
||||
gradeScale: new GradeScale(20),
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: new DateTimeImmutable('2026-03-26 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Infrastructure\Security\GradeParentVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class GradeParentVoterTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private GradeParentVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new GradeParentVoter();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsForUnrelatedAttributes(): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser(Role::PARENT->value);
|
||||
|
||||
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
|
||||
|
||||
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToUnauthenticatedUsers(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
$result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToNonSecurityUserInstances(): void
|
||||
{
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$user->method('getRoles')->willReturn([Role::PARENT->value]);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
$result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToParent(): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser(Role::PARENT->value);
|
||||
|
||||
$result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('nonParentRolesProvider')]
|
||||
public function itDeniesViewToNonParentRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function nonParentRolesProvider(): iterable
|
||||
{
|
||||
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
||||
yield 'ADMIN' => [Role::ADMIN->value];
|
||||
yield 'PROF' => [Role::PROF->value];
|
||||
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
||||
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
||||
yield 'ELEVE' => [Role::ELEVE->value];
|
||||
}
|
||||
|
||||
private function tokenWithSecurityUser(
|
||||
string $role,
|
||||
string $userId = '550e8400-e29b-41d4-a716-446655440001',
|
||||
): TokenInterface {
|
||||
$securityUser = new SecurityUser(
|
||||
UserId::fromString($userId),
|
||||
'test@example.com',
|
||||
'hashed_password',
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
[$role],
|
||||
);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($securityUser);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Infrastructure\Service\DatabaseParentGradeDelayReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DatabaseParentGradeDelayReaderTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
#[Test]
|
||||
public function itReturnsConfiguredDelayWhenRowExists(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection
|
||||
->method('fetchOne')
|
||||
->willReturn('48');
|
||||
|
||||
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||
|
||||
$result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertSame(48, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsDefault24HoursWhenNoRowFound(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection
|
||||
->method('fetchOne')
|
||||
->willReturn(false);
|
||||
|
||||
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||
|
||||
$result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertSame(24, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsDefault24HoursWhenResultIsNonNumeric(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection
|
||||
->method('fetchOne')
|
||||
->willReturn('not-a-number');
|
||||
|
||||
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||
|
||||
$result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertSame(24, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPassesTenantIdToQuery(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection
|
||||
->expects(self::once())
|
||||
->method('fetchOne')
|
||||
->with(
|
||||
self::stringContains('tenant_id'),
|
||||
self::equalTo(['tenant_id' => (string) $tenantId]),
|
||||
)
|
||||
->willReturn('12');
|
||||
|
||||
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||
|
||||
$result = $reader->delayHoursForTenant($tenantId);
|
||||
|
||||
self::assertSame(12, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCastsNumericStringToInt(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection
|
||||
->method('fetchOne')
|
||||
->willReturn('0');
|
||||
|
||||
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||
|
||||
$result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertSame(0, $result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user