diff --git a/backend/src/Scolarite/Application/Query/GetChildrenHomework/ChildHomeworkDto.php b/backend/src/Scolarite/Application/Query/GetChildrenHomework/ChildHomeworkDto.php new file mode 100644 index 0000000..7059e3c --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenHomework/ChildHomeworkDto.php @@ -0,0 +1,21 @@ + $homework + */ + public function __construct( + public string $childId, + public string $firstName, + public string $lastName, + public array $homework, + ) { + } +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkDetailHandler.php b/backend/src/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkDetailHandler.php new file mode 100644 index 0000000..2169812 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkDetailHandler.php @@ -0,0 +1,69 @@ +homeworkRepository->findById(HomeworkId::fromString($homeworkId), $tid); + + if ($homework === null) { + return null; + } + + if (!$this->parentHasChildInClass($parentId, $tid, (string) $homework->classId)) { + return null; + } + + $attachments = $this->attachmentRepository->findByHomeworkId($homework->id); + + $subjects = $this->displayReader->subjectDisplay($tenantId, (string) $homework->subjectId); + $teacherNames = $this->displayReader->teacherNames($tenantId, (string) $homework->teacherId); + + return StudentHomeworkDetailDto::fromDomain( + $homework, + $subjects[(string) $homework->subjectId]['name'] ?? '', + $subjects[(string) $homework->subjectId]['color'] ?? null, + $teacherNames[(string) $homework->teacherId] ?? '', + $attachments, + ); + } + + private function parentHasChildInClass(string $parentId, TenantId $tenantId, string $homeworkClassId): bool + { + $children = $this->parentChildrenReader->childrenOf($parentId, $tenantId); + + foreach ($children as $child) { + $classId = $this->studentClassReader->currentClassId($child['studentId'], $tenantId); + + if ($classId === $homeworkClassId) { + return true; + } + } + + return false; + } +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkHandler.php b/backend/src/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkHandler.php new file mode 100644 index 0000000..ffe50c3 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkHandler.php @@ -0,0 +1,133 @@ + */ + public function __invoke(GetChildrenHomeworkQuery $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 []; + } + + $result = []; + + foreach ($children as $child) { + $classId = $this->studentClassReader->currentClassId($child['studentId'], $tenantId); + + if ($classId === null) { + $result[] = new ChildHomeworkDto( + childId: $child['studentId'], + firstName: $child['firstName'], + lastName: $child['lastName'], + homework: [], + ); + + continue; + } + + $homeworks = $this->homeworkRepository->findByClass(ClassId::fromString($classId), $tenantId); + + if ($query->subjectId !== null) { + $filterSubjectId = $query->subjectId; + $homeworks = array_values(array_filter( + $homeworks, + static fn (Homework $h): bool => (string) $h->subjectId === $filterSubjectId, + )); + } + + usort($homeworks, static fn (Homework $a, Homework $b): int => $a->dueDate <=> $b->dueDate); + + $enriched = $this->enrichHomeworks($homeworks, $query->tenantId); + + $result[] = new ChildHomeworkDto( + childId: $child['studentId'], + firstName: $child['firstName'], + lastName: $child['lastName'], + homework: $enriched, + ); + } + + return $result; + } + + /** + * @param array $homeworks + * + * @return array + */ + private function enrichHomeworks(array $homeworks, string $tenantId): array + { + if ($homeworks === []) { + return []; + } + + $subjectIds = array_values(array_unique( + array_map(static fn (Homework $h): string => (string) $h->subjectId, $homeworks), + )); + $teacherIds = array_values(array_unique( + array_map(static fn (Homework $h): string => (string) $h->teacherId, $homeworks), + )); + + $subjects = $this->displayReader->subjectDisplay($tenantId, ...$subjectIds); + $teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds); + + $homeworkIds = array_map(static fn (Homework $h): HomeworkId => $h->id, $homeworks); + $attachmentMap = $this->attachmentRepository->hasAttachments(...$homeworkIds); + + return array_map( + static fn (Homework $h): StudentHomeworkDto => StudentHomeworkDto::fromDomain( + $h, + $subjects[(string) $h->subjectId]['name'] ?? '', + $subjects[(string) $h->subjectId]['color'] ?? null, + $teacherNames[(string) $h->teacherId] ?? '', + $attachmentMap[(string) $h->id] ?? false, + ), + $homeworks, + ); + } +} diff --git a/backend/src/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkQuery.php b/backend/src/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkQuery.php new file mode 100644 index 0000000..a801a27 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkQuery.php @@ -0,0 +1,16 @@ +getSecurityUser(); + $subjectId = $request->query->get('subjectId'); + + $children = ($this->handler)(new GetChildrenHomeworkQuery( + parentId: $user->userId(), + tenantId: $user->tenantId(), + childId: $childId, + subjectId: is_string($subjectId) && $subjectId !== '' ? $subjectId : null, + )); + + if ($children === []) { + throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.'); + } + + return new JsonResponse([ + 'data' => $this->serializeChild($children[0]), + ]); + } + + /** + * Vue consolidée des devoirs de tous les enfants. + */ + #[Route('/api/me/children/homework', name: 'api_parent_children_homework', methods: ['GET'])] + public function allChildrenHomework(Request $request): JsonResponse + { + $user = $this->getSecurityUser(); + $subjectId = $request->query->get('subjectId'); + + $children = ($this->handler)(new GetChildrenHomeworkQuery( + parentId: $user->userId(), + tenantId: $user->tenantId(), + subjectId: is_string($subjectId) && $subjectId !== '' ? $subjectId : null, + )); + + return new JsonResponse([ + 'data' => array_map($this->serializeChild(...), $children), + ]); + } + + /** + * Détail d'un devoir (accessible si l'enfant du parent est dans la classe du devoir). + */ + #[Route('/api/me/children/homework/{id}', name: 'api_parent_child_homework_detail', methods: ['GET'])] + public function homeworkDetail(string $id): JsonResponse + { + $user = $this->getSecurityUser(); + + $detail = ($this->detailHandler)($user->userId(), $user->tenantId(), $id); + + if ($detail === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + return new JsonResponse(['data' => $this->serializeDetail($detail)]); + } + + /** + * Téléchargement d'une pièce jointe (parent). + */ + #[Route('/api/me/children/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_parent_child_homework_attachment', methods: ['GET'])] + public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + + $homework = $this->homeworkRepository->findById(HomeworkId::fromString($homeworkId), $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + // Verify parent has a child in the homework's class + $detail = ($this->detailHandler)($user->userId(), $user->tenantId(), $homeworkId); + + if ($detail === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + $attachments = $this->attachmentRepository->findByHomeworkId($homework->id); + + foreach ($attachments as $attachment) { + if ((string) $attachment->id === $attachmentId) { + $realPath = realpath($attachment->filePath); + $realUploadsDir = realpath($this->uploadsDir); + + if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) { + throw new NotFoundHttpException('Pièce jointe non trouvée.'); + } + + $response = new BinaryFileResponse($realPath); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_INLINE, + $attachment->filename, + ); + + return $response; + } + } + + throw new NotFoundHttpException('Pièce jointe non trouvée.'); + } + + private function getSecurityUser(): SecurityUser + { + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new AccessDeniedHttpException('Authentification requise.'); + } + + return $user; + } + + /** + * @return array + */ + private function serializeChild(ChildHomeworkDto $child): array + { + return [ + 'childId' => $child->childId, + 'firstName' => $child->firstName, + 'lastName' => $child->lastName, + 'homework' => array_map($this->serializeHomework(...), $child->homework), + ]; + } + + /** + * @return array + */ + private function serializeDetail(StudentHomeworkDetailDto $dto): array + { + return [ + 'id' => $dto->id, + 'subjectId' => $dto->subjectId, + 'subjectName' => $dto->subjectName, + 'subjectColor' => $dto->subjectColor, + 'teacherId' => $dto->teacherId, + 'teacherName' => $dto->teacherName, + 'title' => $dto->title, + 'description' => $dto->description, + 'dueDate' => $dto->dueDate, + 'createdAt' => $dto->createdAt, + 'attachments' => array_map( + static fn ($a): array => [ + 'id' => $a->id, + 'filename' => $a->filename, + 'fileSize' => $a->fileSize, + 'mimeType' => $a->mimeType, + ], + $dto->attachments, + ), + ]; + } + + /** + * @return array + */ + private function serializeHomework(StudentHomeworkDto $dto): array + { + return [ + 'id' => $dto->id, + 'subjectId' => $dto->subjectId, + 'subjectName' => $dto->subjectName, + 'subjectColor' => $dto->subjectColor, + 'teacherId' => $dto->teacherId, + 'teacherName' => $dto->teacherName, + 'title' => $dto->title, + 'description' => $dto->description, + 'dueDate' => $dto->dueDate, + 'createdAt' => $dto->createdAt, + 'hasAttachments' => $dto->hasAttachments, + ]; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Security/HomeworkParentVoter.php b/backend/src/Scolarite/Infrastructure/Security/HomeworkParentVoter.php new file mode 100644 index 0000000..b218542 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Security/HomeworkParentVoter.php @@ -0,0 +1,43 @@ + + */ +final class HomeworkParentVoter extends Voter +{ + public const string VIEW = 'HOMEWORK_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); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkDetailHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkDetailHandlerTest.php new file mode 100644 index 0000000..3bd156f --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkDetailHandlerTest.php @@ -0,0 +1,285 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->attachmentRepository = new InMemoryHomeworkAttachmentRepository(); + } + + #[Test] + public function itReturnsNullWhenHomeworkDoesNotExist(): void + { + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [self::CHILD_A_ID => self::CLASS_A_ID], + ); + + $result = $handler( + self::PARENT_ID, + self::TENANT_ID, + '550e8400-e29b-41d4-a716-446655449999', + ); + + self::assertNull($result); + } + + #[Test] + public function itReturnsNullWhenParentHasNoChildInHomeworkClass(): void + { + $homework = $this->givenHomework( + title: 'Exercices chapitre 5', + dueDate: '2026-04-15', + classId: self::CLASS_B_ID, + ); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [self::CHILD_A_ID => self::CLASS_A_ID], + ); + + $result = $handler( + self::PARENT_ID, + self::TENANT_ID, + (string) $homework->id, + ); + + self::assertNull($result); + } + + #[Test] + public function itReturnsDetailWhenParentHasChildInClass(): void + { + $homework = $this->givenHomework( + title: 'Exercices chapitre 5', + dueDate: '2026-04-15', + classId: self::CLASS_A_ID, + ); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [self::CHILD_A_ID => self::CLASS_A_ID], + ); + + $result = $handler( + self::PARENT_ID, + self::TENANT_ID, + (string) $homework->id, + ); + + self::assertNotNull($result); + self::assertSame((string) $homework->id, $result->id); + self::assertSame('Exercices chapitre 5', $result->title); + self::assertSame('2026-04-15', $result->dueDate); + self::assertSame([], $result->attachments); + } + + #[Test] + public function itReturnsDetailWithAttachments(): void + { + $homework = $this->givenHomework( + title: 'Devoir avec pièces jointes', + dueDate: '2026-04-20', + classId: self::CLASS_A_ID, + ); + + $attachment = new HomeworkAttachment( + id: HomeworkAttachmentId::generate(), + filename: 'exercice.pdf', + filePath: '/uploads/exercice.pdf', + fileSize: 2048, + mimeType: 'application/pdf', + uploadedAt: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + $this->attachmentRepository->save($homework->id, $attachment); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [self::CHILD_A_ID => self::CLASS_A_ID], + ); + + $result = $handler( + self::PARENT_ID, + self::TENANT_ID, + (string) $homework->id, + ); + + self::assertNotNull($result); + self::assertCount(1, $result->attachments); + self::assertSame((string) $attachment->id, $result->attachments[0]->id); + self::assertSame('exercice.pdf', $result->attachments[0]->filename); + self::assertSame(2048, $result->attachments[0]->fileSize); + self::assertSame('application/pdf', $result->attachments[0]->mimeType); + } + + #[Test] + public function itReturnsCorrectSubjectAndTeacherInfo(): void + { + $homework = $this->givenHomework( + title: 'Révisions', + dueDate: '2026-04-18', + classId: self::CLASS_A_ID, + ); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [self::CHILD_A_ID => self::CLASS_A_ID], + subjectName: 'Physique-Chimie', + subjectColor: '#ef4444', + teacherName: 'Marie Martin', + ); + + $result = $handler( + self::PARENT_ID, + self::TENANT_ID, + (string) $homework->id, + ); + + self::assertNotNull($result); + self::assertSame(self::SUBJECT_MATH_ID, $result->subjectId); + self::assertSame('Physique-Chimie', $result->subjectName); + self::assertSame('#ef4444', $result->subjectColor); + self::assertSame(self::TEACHER_ID, $result->teacherId); + self::assertSame('Marie Martin', $result->teacherName); + } + + /** + * @param array $children + * @param array $classMap studentId => classId + */ + private function createHandler( + array $children = [], + array $classMap = [], + string $subjectName = 'Mathématiques', + ?string $subjectColor = '#3b82f6', + string $teacherName = 'Jean Dupont', + ): GetChildrenHomeworkDetailHandler { + $parentChildrenReader = new class($children) implements ParentChildrenReader { + /** @param array $children */ + public function __construct(private readonly array $children) + { + } + + public function childrenOf(string $guardianId, TenantId $tenantId): array + { + return $this->children; + } + }; + + $studentClassReader = new class($classMap) implements StudentClassReader { + /** @param array $classMap */ + public function __construct(private readonly array $classMap) + { + } + + public function currentClassId(string $studentId, TenantId $tenantId): ?string + { + return $this->classMap[$studentId] ?? null; + } + }; + + $displayReader = new class($subjectName, $subjectColor, $teacherName) implements ScheduleDisplayReader { + public function __construct( + private readonly string $subjectName, + private readonly ?string $subjectColor, + private readonly string $teacherName, + ) { + } + + public function subjectDisplay(string $tenantId, string ...$subjectIds): array + { + $map = []; + + foreach ($subjectIds as $id) { + $map[$id] = ['name' => $this->subjectName, 'color' => $this->subjectColor]; + } + + return $map; + } + + public function teacherNames(string $tenantId, string ...$teacherIds): array + { + $map = []; + + foreach ($teacherIds as $id) { + $map[$id] = $this->teacherName; + } + + return $map; + } + }; + + return new GetChildrenHomeworkDetailHandler( + $parentChildrenReader, + $studentClassReader, + $this->homeworkRepository, + $this->attachmentRepository, + $displayReader, + ); + } + + private function givenHomework( + string $title, + string $dueDate, + string $classId = self::CLASS_A_ID, + string $subjectId = self::SUBJECT_MATH_ID, + ): Homework { + $homework = Homework::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, + dueDate: new DateTimeImmutable($dueDate), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + + $this->homeworkRepository->save($homework); + + return $homework; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkHandlerTest.php new file mode 100644 index 0000000..7e94c24 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetChildrenHomework/GetChildrenHomeworkHandlerTest.php @@ -0,0 +1,315 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->attachmentRepository = new InMemoryHomeworkAttachmentRepository(); + } + + #[Test] + public function itReturnsEmptyWhenParentHasNoChildren(): void + { + $handler = $this->createHandler(children: []); + + $result = $handler(new GetChildrenHomeworkQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertSame([], $result); + } + + #[Test] + public function itReturnsHomeworkForSingleChild(): void + { + $this->givenHomework(title: 'Exercices chapitre 5', dueDate: '2026-04-15', classId: self::CLASS_A_ID); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [self::CHILD_A_ID => self::CLASS_A_ID], + ); + + $result = $handler(new GetChildrenHomeworkQuery( + 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]->homework); + self::assertSame('Exercices chapitre 5', $result[0]->homework[0]->title); + } + + #[Test] + public function itReturnsHomeworkForMultipleChildren(): void + { + $this->givenHomework(title: 'Maths 6A', dueDate: '2026-04-15', classId: self::CLASS_A_ID); + $this->givenHomework(title: 'Maths 6B', dueDate: '2026-04-16', classId: self::CLASS_B_ID); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'], + ], + classMap: [ + self::CHILD_A_ID => self::CLASS_A_ID, + self::CHILD_B_ID => self::CLASS_B_ID, + ], + ); + + $result = $handler(new GetChildrenHomeworkQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(2, $result); + self::assertSame('Emma', $result[0]->firstName); + self::assertCount(1, $result[0]->homework); + self::assertSame('Maths 6A', $result[0]->homework[0]->title); + self::assertSame('Lucas', $result[1]->firstName); + self::assertCount(1, $result[1]->homework); + self::assertSame('Maths 6B', $result[1]->homework[0]->title); + } + + #[Test] + public function itFiltersToSpecificChildWhenChildIdProvided(): void + { + $this->givenHomework(title: 'Maths 6A', dueDate: '2026-04-15', classId: self::CLASS_A_ID); + $this->givenHomework(title: 'Maths 6B', dueDate: '2026-04-16', classId: self::CLASS_B_ID); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'], + ], + classMap: [ + self::CHILD_A_ID => self::CLASS_A_ID, + self::CHILD_B_ID => self::CLASS_B_ID, + ], + ); + + $result = $handler(new GetChildrenHomeworkQuery( + 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]->homework); + self::assertSame('Maths 6B', $result[0]->homework[0]->title); + } + + #[Test] + public function itReturnsEmptyHomeworkWhenChildHasNoClass(): void + { + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [], + ); + + $result = $handler(new GetChildrenHomeworkQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame('Emma', $result[0]->firstName); + self::assertSame([], $result[0]->homework); + } + + #[Test] + public function itReturnsEmptyWhenChildIdDoesNotMatchAnyChild(): void + { + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [self::CHILD_A_ID => self::CLASS_A_ID], + ); + + $result = $handler(new GetChildrenHomeworkQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + childId: '550e8400-e29b-41d4-a716-446655449999', + )); + + self::assertSame([], $result); + } + + #[Test] + public function itFiltersBySubjectWhenProvided(): void + { + $this->givenHomework(title: 'Maths', dueDate: '2026-04-15', classId: self::CLASS_A_ID, subjectId: self::SUBJECT_MATH_ID); + $this->givenHomework(title: 'Français', dueDate: '2026-04-16', classId: self::CLASS_A_ID, subjectId: self::SUBJECT_FRENCH_ID); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [self::CHILD_A_ID => self::CLASS_A_ID], + ); + + $result = $handler(new GetChildrenHomeworkQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_MATH_ID, + )); + + self::assertCount(1, $result); + self::assertCount(1, $result[0]->homework); + self::assertSame('Maths', $result[0]->homework[0]->title); + } + + #[Test] + public function itSortsHomeworkByDueDateAscending(): void + { + $this->givenHomework(title: 'Lointain', dueDate: '2026-05-20', classId: self::CLASS_A_ID); + $this->givenHomework(title: 'Proche', dueDate: '2026-04-10', classId: self::CLASS_A_ID); + $this->givenHomework(title: 'Milieu', dueDate: '2026-04-25', classId: self::CLASS_A_ID); + + $handler = $this->createHandler( + children: [ + ['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'], + ], + classMap: [self::CHILD_A_ID => self::CLASS_A_ID], + ); + + $result = $handler(new GetChildrenHomeworkQuery( + parentId: self::PARENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + $titles = array_map(static fn ($hw) => $hw->title, $result[0]->homework); + self::assertSame(['Proche', 'Milieu', 'Lointain'], $titles); + } + + /** + * @param array $children + * @param array $classMap studentId => classId + */ + private function createHandler( + array $children = [], + array $classMap = [], + ): GetChildrenHomeworkHandler { + $parentChildrenReader = new class($children) implements ParentChildrenReader { + /** @param array $children */ + public function __construct(private readonly array $children) + { + } + + public function childrenOf(string $guardianId, TenantId $tenantId): array + { + return $this->children; + } + }; + + $studentClassReader = new class($classMap) implements StudentClassReader { + /** @param array $classMap */ + public function __construct(private readonly array $classMap) + { + } + + public function currentClassId(string $studentId, TenantId $tenantId): ?string + { + return $this->classMap[$studentId] ?? null; + } + }; + + $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; + } + }; + + return new GetChildrenHomeworkHandler( + $parentChildrenReader, + $studentClassReader, + $this->homeworkRepository, + $this->attachmentRepository, + $displayReader, + ); + } + + private function givenHomework( + string $title, + string $dueDate, + string $classId = self::CLASS_A_ID, + string $subjectId = self::SUBJECT_MATH_ID, + ): Homework { + $homework = Homework::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, + dueDate: new DateTimeImmutable($dueDate), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + + $this->homeworkRepository->save($homework); + + return $homework; + } +} diff --git a/frontend/e2e/parent-homework.spec.ts b/frontend/e2e/parent-homework.spec.ts new file mode 100644 index 0000000..24afda7 --- /dev/null +++ b/frontend/e2e/parent-homework.spec.ts @@ -0,0 +1,750 @@ +import { test, expect, type Page } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-parenthw-admin@example.com'; +const ADMIN_PASSWORD = 'AdminParentHW123'; +const PARENT_EMAIL = 'e2e-parenthw-parent@example.com'; +const PARENT_PASSWORD = 'ParentHomework123'; +const TEACHER_EMAIL = 'e2e-parenthw-teacher@example.com'; +const TEACHER_PASSWORD = 'TeacherParentHW123'; +const STUDENT1_EMAIL = 'e2e-parenthw-student1@example.com'; +const STUDENT1_PASSWORD = 'Student1ParentHW123'; +const STUDENT2_EMAIL = 'e2e-parenthw-student2@example.com'; +const STUDENT2_PASSWORD = 'Student2ParentHW123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +let student1UserId: string; +let student2UserId: string; + +function runSql(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function clearCache() { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pool may not exist + } +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId: schoolId!, academicYearId: academicYearId! }; +} + +function extractUserId(output: string): string { + const match = output.match(/User ID\s+([a-f0-9-]{36})/i); + if (!match) { + throw new Error(`Could not extract User ID from command output:\n${output}`); + } + return match[1]; +} + +function getNextWeekday(daysFromNow: number): string { + const date = new Date(); + date.setDate(date.getDate() + daysFromNow); + const day = date.getDay(); + if (day === 0) date.setDate(date.getDate() + 1); + if (day === 6) date.setDate(date.getDate() + 2); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function getTomorrowWeekday(): string { + return getNextWeekday(1); +} + +function getFormattedToday(): string { + const date = new Date(); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function getPastDate(daysAgo: number): string { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +async function loginAsAdmin(page: Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function loginAsParent(page: Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(PARENT_EMAIL); + await page.locator('#password').fill(PARENT_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function addGuardianIfNotLinked(page: Page, studentId: string, parentSearchTerm: string, relationship: string) { + await page.goto(`${ALPHA_URL}/admin/students/${studentId}`); + await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 }); + await expect( + page.getByText(/aucun parent\/tuteur/i).or(page.locator('.guardian-list')) + ).toBeVisible({ timeout: 10000 }); + + const addButton = page.getByRole('button', { name: /ajouter un parent/i }); + if (!(await addButton.isVisible())) return; + + const sectionText = await page.locator('.guardian-section').textContent(); + if (sectionText && sectionText.includes(parentSearchTerm)) return; + + await addButton.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + const searchInput = dialog.getByRole('combobox', { name: /rechercher/i }); + await searchInput.fill(parentSearchTerm); + + const listbox = dialog.locator('#parent-search-listbox'); + await expect(listbox).toBeVisible({ timeout: 10000 }); + const option = listbox.locator('[role="option"]').first(); + await option.click(); + + await expect(dialog.getByText(/sélectionné/i)).toBeVisible(); + + await dialog.getByLabel(/type de relation/i).selectOption(relationship); + await dialog.getByRole('button', { name: 'Ajouter' }).click(); + + await expect( + page.locator('.alert-success').or(page.locator('.alert-error')) + ).toBeVisible({ timeout: 10000 }); +} + +test.describe('Parent Homework Consultation (Story 5.8)', () => { + test.describe.configure({ mode: 'serial', timeout: 60000 }); + + const urgentDueDate = getTomorrowWeekday(); + const futureDueDate = getNextWeekday(10); + const todayDueDate = getFormattedToday(); + const overdueDueDate = getPastDate(3); + + test.beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(120000); + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pools may not exist + } + + // Create users + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT --firstName=ParentHW --lastName=TestUser 2>&1`, + { encoding: 'utf-8' } + ); + + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + const student1Output = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT1_EMAIL} --password=${STUDENT1_PASSWORD} --role=ROLE_ELEVE --firstName=Emma --lastName=ParentHWTest 2>&1`, + { encoding: 'utf-8' } + ); + student1UserId = extractUserId(student1Output); + + const student2Output = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT2_EMAIL} --password=${STUDENT2_PASSWORD} --role=ROLE_ELEVE --firstName=Lucas --lastName=ParentHWTest 2>&1`, + { encoding: 'utf-8' } + ); + student2UserId = extractUserId(student2Output); + + const { schoolId, academicYearId } = resolveDeterministicIds(); + + // Ensure classes exist + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-PHW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-PHW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Ensure subjects exist + try { + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-PHW-Maths', 'E2EPHWMAT', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-PHW-Français', 'E2EPHWFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Assign students to classes + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` + + `FROM users u, school_classes c ` + + `WHERE u.email = '${STUDENT1_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.name = 'E2E-PHW-6A' AND c.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` + + `FROM users u, school_classes c ` + + `WHERE u.email = '${STUDENT2_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.name = 'E2E-PHW-6B' AND c.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + + // Clean up stale homework from previous runs + try { + runSql( + `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` + + `(SELECT id FROM school_classes WHERE name IN ('E2E-PHW-6A', 'E2E-PHW-6B') AND tenant_id = '${TENANT_ID}')` + ); + } catch { + // Table may not exist + } + + // Seed homework for both classes + // Urgent homework (due tomorrow) for class 6A + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Devoir urgent maths', 'Exercices urgents', '${urgentDueDate}', 'published', NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-PHW-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + + // Future homework for class 6A + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Rédaction français Emma', 'Écrire une rédaction', '${futureDueDate}', 'published', NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2EPHWFRA' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-PHW-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + + // Homework for class 6B (Lucas) + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Exercices maths Lucas', 'Exercices chapitre 7', '${futureDueDate}', 'published', NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-PHW-6B' AND c.tenant_id = '${TENANT_ID}'` + ); + + // Homework due TODAY for class 6A ("Aujourd'hui" badge) + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Devoir maths aujourd''hui', 'Exercices pour aujourd''hui', '${todayDueDate}', 'published', NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-PHW-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + + // Overdue homework for class 6A ("En retard" badge) + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Devoir français en retard', 'Exercices en retard', '${overdueDueDate}', 'published', NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2EPHWFRA' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-PHW-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + + // Link parent to both students via admin UI + const page = await browser.newPage(); + await loginAsAdmin(page); + await addGuardianIfNotLinked(page, student1UserId, PARENT_EMAIL, 'tuteur'); + await addGuardianIfNotLinked(page, student2UserId, PARENT_EMAIL, 'tutrice'); + await page.close(); + + clearCache(); + }); + + // ====================================================================== + // AC1: Liste devoirs enfant + // ====================================================================== + test.describe('AC1: Homework List', () => { + test('parent can navigate to homework page via navigation', async ({ page }) => { + await loginAsParent(page); + + const nav = page.locator('.desktop-nav'); + await expect(nav.getByRole('link', { name: /devoirs/i })).toBeVisible({ timeout: 15000 }); + }); + + test('parent homework page shows homework list', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect( + page.getByRole('heading', { name: /devoirs des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + + // Homework cards should be visible + const cards = page.locator('.homework-card'); + await expect(cards.first()).toBeVisible({ timeout: 10000 }); + }); + }); + + // ====================================================================== + // AC2: Vue identique élève (sans marquage "Fait") + // ====================================================================== + test.describe('AC2: Student-like View Without Done Toggle', () => { + test('homework cards do NOT show done toggle checkbox', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + + // No toggle-done button should exist (privacy) + await expect(card.locator('.toggle-done')).toHaveCount(0); + }); + + test('homework cards show title and due date', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + + // Title visible + await expect(card.locator('.card-title')).toBeVisible(); + // Due date visible + await expect(card.locator('.due-date')).toBeVisible(); + }); + }); + + // ====================================================================== + // AC3: Vue multi-enfants + // ====================================================================== + test.describe('AC3: Multi-Child View', () => { + test('parent with multiple children sees child selector', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + const childSelector = page.locator('.child-selector'); + await expect(childSelector).toBeVisible({ timeout: 10000 }); + + // Should have "Tous" + 2 children buttons + const buttons = childSelector.locator('.child-button'); + await expect(buttons).toHaveCount(3); + }); + + test('consolidated view shows homework grouped by child', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + // Wait for data to load + const card = page.locator('[data-testid="homework-card"]').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + + // Both children's names should appear as section headers + const childNames = page.locator('[data-testid="child-name"]'); + await expect(childNames).toHaveCount(2, { timeout: 10000 }); + }); + + test('clicking a specific child filters to their homework', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + const childSelector = page.locator('.child-selector'); + await expect(childSelector).toBeVisible({ timeout: 10000 }); + + // Click on first child (Emma) + const buttons = childSelector.locator('.child-button'); + await buttons.nth(1).click(); + + // Wait for data to reload + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + + // Should no longer show multiple child sections + await expect(page.locator('.child-name')).toHaveCount(0, { timeout: 5000 }); + }); + }); + + // ====================================================================== + // AC4: Mise en évidence urgence + // ====================================================================== + test.describe('AC4: Urgency Highlight', () => { + test('homework due tomorrow shows urgent badge', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // Find urgent badge — text depends on when test runs relative to seeded date + const urgentBadge = page.locator('[data-testid="urgent-badge"]'); + await expect(urgentBadge.first()).toBeVisible({ timeout: 5000 }); + await expect(urgentBadge.first()).toContainText(/pour demain|aujourd'hui|en retard/i); + }); + + test('urgent homework card has red styling', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // Urgent card should have the urgent class + const urgentCard = page.locator('[data-testid="homework-card"].urgent'); + await expect(urgentCard.first()).toBeVisible({ timeout: 5000 }); + }); + + test('urgent homework shows contact teacher link', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // Contact teacher link should be visible on urgent homework + const contactLink = page.locator('[data-testid="contact-teacher"]'); + await expect(contactLink.first()).toBeVisible({ timeout: 5000 }); + await expect(contactLink.first()).toContainText(/contacter l'enseignant/i); + }); + + test('contact teacher link points to messaging page', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + const contactLink = page.locator('[data-testid="contact-teacher"]').first(); + await expect(contactLink).toBeVisible({ timeout: 5000 }); + + // Verify href contains message creation path with proper encoding + const href = await contactLink.getAttribute('href'); + expect(href).toContain('/messages/new'); + expect(href).toContain('to='); + expect(href).toContain('subject=Devoir'); + }); + }); + + // ====================================================================== + // Homework detail + // ====================================================================== + test.describe('Homework Detail', () => { + test('clicking a homework card shows detail view', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + await card.click(); + + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.detail-title')).toBeVisible(); + }); + + test('back button returns to homework list', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + const card = page.locator('.homework-card').first(); + await expect(card).toBeVisible({ timeout: 10000 }); + await card.click(); + + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + + // Click back + await page.locator('.back-button').click(); + await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 5000 }); + }); + }); + + // ====================================================================== + // AC4 Extended: Urgency Badge Variants + // ====================================================================== + test.describe('AC4 Extended: Urgency Badge Variants', () => { + test('homework due today shows "Aujourd\'hui" badge', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // Find the card for "Devoir maths aujourd'hui" + const todayCard = page.locator('[data-testid="homework-card"]', { + has: page.locator('.card-title', { hasText: "Devoir maths aujourd'hui" }) + }); + await expect(todayCard).toBeVisible({ timeout: 10000 }); + + // Verify urgent badge shows "Aujourd'hui" + const badge = todayCard.locator('[data-testid="urgent-badge"]'); + await expect(badge).toBeVisible({ timeout: 5000 }); + await expect(badge).toContainText("Aujourd'hui"); + + // Badge should NOT have the overdue class + await expect(badge).not.toHaveClass(/overdue/); + }); + + test('overdue homework shows "En retard" badge with overdue styling', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // Find the card for the overdue homework + const overdueCard = page.locator('[data-testid="homework-card"]', { + has: page.locator('.card-title', { hasText: 'Devoir français en retard' }) + }); + await expect(overdueCard).toBeVisible({ timeout: 10000 }); + + // Verify urgent badge shows "En retard" + const badge = overdueCard.locator('[data-testid="urgent-badge"]'); + await expect(badge).toBeVisible({ timeout: 5000 }); + await expect(badge).toContainText('En retard'); + + // Badge should have the overdue class for stronger styling + await expect(badge).toHaveClass(/overdue/); + }); + + test('overdue homework card has urgent styling', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // The overdue card should also have the .urgent class + const overdueCard = page.locator('[data-testid="homework-card"]', { + has: page.locator('.card-title', { hasText: 'Devoir français en retard' }) + }); + await expect(overdueCard).toHaveClass(/urgent/); + }); + }); + + // ====================================================================== + // AC1 Extended: Subject Filter + // ====================================================================== + test.describe('AC1 Extended: Subject Filter', () => { + test('subject filter chips are visible when multiple subjects exist', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // Filter bar should be visible + const filterBar = page.locator('.filter-bar'); + await expect(filterBar).toBeVisible({ timeout: 5000 }); + + // Should have "Tous" chip + subject chips (at least Maths and Français) + const chips = filterBar.locator('.filter-chip'); + const chipCount = await chips.count(); + expect(chipCount).toBeGreaterThanOrEqual(3); // Tous + Maths + Français + + // "Tous" chip should be active by default + const tousChip = filterBar.locator('.filter-chip', { hasText: 'Tous' }); + await expect(tousChip).toBeVisible(); + await expect(tousChip).toHaveClass(/active/); + }); + + test('clicking a subject filter shows only homework of that subject', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // Count all homework cards before filtering + const allCardsCount = await page.locator('[data-testid="homework-card"]').count(); + expect(allCardsCount).toBeGreaterThanOrEqual(2); + + // Click on the "E2E-PHW-Français" filter chip + const filterBar = page.locator('.filter-bar'); + const francaisChip = filterBar.locator('.filter-chip', { hasText: /Français/i }); + await expect(francaisChip).toBeVisible({ timeout: 5000 }); + await francaisChip.click(); + + // Wait for the filter to be applied (chip becomes active) + await expect(francaisChip).toHaveClass(/active/, { timeout: 5000 }); + + // Wait for homework cards to update + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // All visible cards should be Français homework + const filteredCards = page.locator('[data-testid="homework-card"]'); + const filteredCount = await filteredCards.count(); + expect(filteredCount).toBeLessThan(allCardsCount); + + // Each visible card should show the Français subject name + for (let i = 0; i < filteredCount; i++) { + await expect(filteredCards.nth(i).locator('.subject-name')).toContainText(/Français/i); + } + }); + + test('clicking "Tous" resets the subject filter', async ({ page }) => { + await loginAsParent(page); + await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); + + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // Apply a filter first + const filterBar = page.locator('.filter-bar'); + const francaisChip = filterBar.locator('.filter-chip', { hasText: /Français/i }); + await francaisChip.click(); + await expect(francaisChip).toHaveClass(/active/, { timeout: 5000 }); + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + const filteredCount = await page.locator('[data-testid="homework-card"]').count(); + + // Now click "Tous" to reset + const tousChip = filterBar.locator('.filter-chip', { hasText: 'Tous' }); + await tousChip.click(); + await expect(tousChip).toHaveClass(/active/, { timeout: 5000 }); + await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); + + // Card count should be greater than filtered count + const resetCount = await page.locator('[data-testid="homework-card"]').count(); + expect(resetCount).toBeGreaterThan(filteredCount); + }); + }); + + // ====================================================================== + // Dashboard Widget + // ====================================================================== + test.describe('Dashboard Widget', () => { + test('dashboard shows homework widget with child names', async ({ page }) => { + await loginAsParent(page); + + // Dashboard should load + await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 }); + + // Homework section should show items + const homeworkSection = page.locator('.dashboard-grid').locator('section', { hasText: /devoirs à venir/i }); + await expect(homeworkSection).toBeVisible({ timeout: 10000 }); + + // Items should be clickable buttons + const homeworkBtn = homeworkSection.locator('button.homework-item').first(); + await expect(homeworkBtn).toBeVisible({ timeout: 10000 }); + + // Child name should be visible on items + await expect(homeworkSection.locator('.homework-child').first()).toBeVisible(); + }); + + test('dashboard shows homework from multiple children sorted by date', async ({ page }) => { + await loginAsParent(page); + + await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 }); + + // Wait for homework buttons to load + const homeworkBtn = page.locator('button.homework-item').first(); + await expect(homeworkBtn).toBeVisible({ timeout: 10000 }); + + // Should see homework from both Emma and Lucas + const childLabels = page.locator('.homework-child'); + const count = await childLabels.count(); + const names = new Set(); + for (let i = 0; i < count; i++) { + const text = await childLabels.nth(i).textContent(); + if (text) names.add(text.trim()); + } + expect(names.size).toBeGreaterThanOrEqual(2); + }); + + test('clicking a homework item opens detail modal', async ({ page }) => { + await loginAsParent(page); + + await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 }); + + const homeworkBtn = page.locator('button.homework-item').first(); + await expect(homeworkBtn).toBeVisible({ timeout: 10000 }); + await homeworkBtn.click(); + + // Modal with detail should appear + const modal = page.locator('[role="dialog"]'); + await expect(modal).toBeVisible({ timeout: 10000 }); + await expect(modal.locator('.detail-title')).toBeVisible(); + await expect(modal.locator('.teacher-name')).toBeVisible(); + }); + + test('homework detail modal closes with X button', async ({ page }) => { + await loginAsParent(page); + + await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 }); + + const homeworkBtn = page.locator('button.homework-item').first(); + await expect(homeworkBtn).toBeVisible({ timeout: 10000 }); + await homeworkBtn.click(); + + const modal = page.locator('[role="dialog"]'); + await expect(modal).toBeVisible({ timeout: 10000 }); + + // Close modal + await page.locator('.homework-modal-close').click(); + await expect(modal).not.toBeVisible({ timeout: 5000 }); + }); + + test('"Voir tous les devoirs" link navigates to homework page', async ({ page }) => { + await loginAsParent(page); + + await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 }); + + await page.getByText(/voir tous les devoirs/i).click(); + await expect( + page.getByRole('heading', { name: /devoirs des enfants/i }) + ).toBeVisible({ timeout: 15000 }); + }); + }); +}); diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardParent.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardParent.svelte index c73d4f6..b0f494b 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardParent.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardParent.svelte @@ -1,8 +1,12 @@ + +{#snippet homeworkCard(hw: StudentHomework)} + {@const level = urgencyLevel(hw.dueDate)} +
handleCardClick(hw.id)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCardClick(hw.id); } }} + > +
+ {hw.subjectName} + {#if level} + + {urgencyLabel(level)} + + {/if} +
+

{hw.title}

+ +
+{/snippet} + +{#if selectedDetail} + +{:else} +
+ + + {#if isOffline()} +
+ + + + + + + + + + Mode hors ligne +
+ {/if} + + {#if allSubjects.length > 1} + + {/if} + + {#if loading} + + {:else if error} + + {:else if allHomework.length === 0} +
+

Aucun devoir pour le moment

+
+ {:else} + {#if childrenData.length > 1} + {#each childrenData as child (child.childId)} + {#if child.homework.length > 0} +
+

{child.firstName} {child.lastName}

+
    + {#each child.homework as hw (hw.id)} +
  • {@render homeworkCard(hw)}
  • + {/each} +
+
+ {/if} + {/each} + {:else} +
    + {#each allHomework as hw (hw.id)} +
  • {@render homeworkCard(hw)}
  • + {/each} +
+ {/if} + {/if} + + {#if detailLoading} +
+

Chargement...

+
+ {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte b/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte index 52cd0d1..3a0e3f1 100644 --- a/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte +++ b/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte @@ -1,14 +1,16 @@ + + + Devoirs des enfants - Classeo + + +
+ + +
+ +