feat: Permettre à l'élève de consulter ses notes et moyennes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-04-05 16:04:26 +02:00
parent b7dc27f2a5
commit 272d31e1c0
59 changed files with 7057 additions and 49 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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'],
);
}
}

View File

@@ -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'),
);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}