feat: Permettre aux parents de consulter l'emploi du temps de leurs enfants
Some checks failed
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

Les parents avaient accès au lien "Emploi du temps" dans la navigation,
mais le dashboard n'affichait aucune donnée réelle : la section EDT
restait un placeholder vide ("L'emploi du temps sera disponible...").

Cette implémentation connecte le dashboard parent aux vrais endpoints API
(GET /api/me/children/{childId}/schedule/day|week/{date} et le résumé
multi-enfants), affiche le ScheduleWidget avec le prochain cours mis en
évidence (AC1), permet de cliquer sur chaque enfant dans le résumé pour
voir son EDT détaillé (AC2), et met en cache les endpoints parent dans le
Service Worker pour le mode offline (AC5).

Le handler backend est optimisé pour ne résoudre que l'enfant demandé
(via childId optionnel dans la query) au lieu de tous les enfants à chaque
appel, et les fonctions utilitaires dupliquées (formatSyncDate, timezone)
sont factorisées.
This commit is contained in:
2026-03-09 00:45:13 +01:00
parent 125d9d8806
commit bf753d1367
23 changed files with 2146 additions and 42 deletions

View File

@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenSchedule;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
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\CurrentCalendarProvider;
use App\Scolarite\Application\Port\ParentChildrenReader;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetChildrenSchedule\ChildScheduleDto;
use App\Scolarite\Application\Query\GetChildrenSchedule\GetChildrenScheduleHandler;
use App\Scolarite\Application\Query\GetChildrenSchedule\GetChildrenScheduleQuery;
use App\Scolarite\Application\Service\ScheduleResolver;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleExceptionRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetChildrenScheduleHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string PARENT_ID = '550e8400-e29b-41d4-a716-446655440099';
private const string CHILD1_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string CHILD2_ID = '550e8400-e29b-41d4-a716-446655440051';
private const string CLASS1_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string CLASS2_ID = '550e8400-e29b-41d4-a716-446655440021';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $slotRepository;
private InMemoryScheduleExceptionRepository $exceptionRepository;
protected function setUp(): void
{
$this->slotRepository = new InMemoryScheduleSlotRepository();
$this->exceptionRepository = new InMemoryScheduleExceptionRepository();
}
#[Test]
public function returnsEmptyWhenParentHasNoChildren(): void
{
$handler = $this->createHandler(children: []);
$result = $handler(new GetChildrenScheduleQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertSame([], $result);
}
#[Test]
public function returnsScheduleForSingleChild(): void
{
$this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00');
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
],
classMapping: [self::CHILD1_ID => self::CLASS1_ID],
);
$result = $handler(new GetChildrenScheduleQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02', // Monday
));
self::assertCount(1, $result);
self::assertInstanceOf(ChildScheduleDto::class, $result[0]);
self::assertSame(self::CHILD1_ID, $result[0]->childId);
self::assertSame('Alice', $result[0]->firstName);
self::assertSame('Dupont', $result[0]->lastName);
self::assertCount(1, $result[0]->slots);
}
#[Test]
public function returnsScheduleForMultipleChildren(): void
{
$this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00');
$this->saveRecurringSlot(self::CLASS2_ID, DayOfWeek::MONDAY, '10:00', '11:00');
$this->saveRecurringSlot(self::CLASS2_ID, DayOfWeek::TUESDAY, '14:00', '15:00');
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
['studentId' => self::CHILD2_ID, 'firstName' => 'Bob', 'lastName' => 'Dupont'],
],
classMapping: [
self::CHILD1_ID => self::CLASS1_ID,
self::CHILD2_ID => self::CLASS2_ID,
],
);
$result = $handler(new GetChildrenScheduleQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertCount(2, $result);
self::assertSame(self::CHILD1_ID, $result[0]->childId);
self::assertCount(1, $result[0]->slots);
self::assertSame(self::CHILD2_ID, $result[1]->childId);
self::assertCount(2, $result[1]->slots);
}
#[Test]
public function returnsEmptySlotsWhenChildHasNoClass(): void
{
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
],
classMapping: [],
);
$result = $handler(new GetChildrenScheduleQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertCount(1, $result);
self::assertSame(self::CHILD1_ID, $result[0]->childId);
self::assertSame([], $result[0]->slots);
}
#[Test]
public function enrichesSlotsWithSubjectAndTeacherNames(): void
{
$this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00');
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
],
classMapping: [self::CHILD1_ID => self::CLASS1_ID],
subjectNames: [self::SUBJECT_ID => 'Mathématiques'],
teacherNames: [self::TEACHER_ID => 'Jean Martin'],
);
$result = $handler(new GetChildrenScheduleQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertSame('Mathématiques', $result[0]->slots[0]->subjectName);
self::assertSame('Jean Martin', $result[0]->slots[0]->teacherName);
}
#[Test]
public function computesMondayFromAnyDayOfWeek(): void
{
$this->saveRecurringSlot(self::CLASS1_ID, DayOfWeek::MONDAY, '08:00', '09:00');
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD1_ID, 'firstName' => 'Alice', 'lastName' => 'Dupont'],
],
classMapping: [self::CHILD1_ID => self::CLASS1_ID],
);
$result = $handler(new GetChildrenScheduleQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-04', // Wednesday
));
self::assertCount(1, $result);
self::assertSame('2026-03-02', $result[0]->slots[0]->date);
}
private function saveRecurringSlot(
string $classId,
DayOfWeek $day,
string $start,
string $end,
?string $room = null,
): void {
$slot = ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
dayOfWeek: $day,
timeSlot: new TimeSlot($start, $end),
room: $room,
isRecurring: true,
now: new DateTimeImmutable('2026-01-01'),
recurrenceStart: new DateTimeImmutable('2026-01-01'),
);
$this->slotRepository->save($slot);
}
/**
* @param array<array{studentId: string, firstName: string, lastName: string}> $children
* @param array<string, string> $classMapping studentId => classId
* @param array<string, string> $subjectNames
* @param array<string, string> $teacherNames
*/
private function createHandler(
array $children = [],
array $classMapping = [],
array $subjectNames = [],
array $teacherNames = [],
): GetChildrenScheduleHandler {
$tenantId = TenantId::fromString(self::TENANT_ID);
$parentChildrenReader = new class($children) implements ParentChildrenReader {
/** @param array<array{studentId: string, firstName: string, lastName: string}> $children */
public function __construct(private array $children)
{
}
public function childrenOf(string $guardianId, TenantId $tenantId): array
{
return $this->children;
}
};
$studentClassReader = new class($classMapping) implements StudentClassReader {
/** @param array<string, string> $mapping */
public function __construct(private array $mapping)
{
}
public function currentClassId(string $studentId, TenantId $tenantId): ?string
{
return $this->mapping[$studentId] ?? null;
}
};
$calendarProvider = new class($tenantId) implements CurrentCalendarProvider {
public function __construct(private TenantId $tenantId)
{
}
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
{
return SchoolCalendar::initialiser($this->tenantId, AcademicYearId::generate());
}
};
$displayReader = new class($subjectNames, $teacherNames) implements ScheduleDisplayReader {
/** @param array<string, string> $subjects @param array<string, string> $teachers */
public function __construct(
private array $subjects,
private array $teachers,
) {
}
public function subjectNames(string $tenantId, string ...$subjectIds): array
{
return $this->subjects;
}
public function teacherNames(string $tenantId, string ...$teacherIds): array
{
return $this->teachers;
}
};
return new GetChildrenScheduleHandler(
$parentChildrenReader,
$studentClassReader,
new ScheduleResolver($this->slotRepository, $this->exceptionRepository),
$calendarProvider,
$displayReader,
);
}
}