diff --git a/backend/config/services.yaml b/backend/config/services.yaml index a749270..0519e19 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -206,6 +206,20 @@ services: App\Scolarite\Domain\Repository\TeacherReplacementRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineTeacherReplacementRepository + # Homework (Story 5.1 - Création de devoirs) + App\Scolarite\Domain\Repository\HomeworkRepository: + alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRepository + + App\Scolarite\Domain\Service\DueDateValidator: + autowire: true + + App\Scolarite\Application\Port\FileStorage: + alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage + + App\Scolarite\Infrastructure\Storage\LocalFileStorage: + arguments: + $storagePath: '%kernel.project_dir%/var/storage' + # Schedule (Story 4.1 - Emploi du temps) App\Scolarite\Domain\Repository\ScheduleSlotRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository diff --git a/backend/migrations/Version20260312002349.php b/backend/migrations/Version20260312002349.php new file mode 100644 index 0000000..e3b4675 --- /dev/null +++ b/backend/migrations/Version20260312002349.php @@ -0,0 +1,60 @@ +addSql(<<<'SQL' + CREATE TABLE homework ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + class_id UUID NOT NULL REFERENCES school_classes(id), + subject_id UUID NOT NULL REFERENCES subjects(id), + teacher_id UUID NOT NULL REFERENCES users(id), + title VARCHAR(255) NOT NULL, + description TEXT, + due_date DATE NOT NULL, + status VARCHAR(20) DEFAULT 'published' NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $this->addSql('CREATE INDEX idx_homework_tenant ON homework(tenant_id)'); + $this->addSql('CREATE INDEX idx_homework_class ON homework(class_id)'); + $this->addSql('CREATE INDEX idx_homework_due_date ON homework(due_date)'); + $this->addSql('CREATE INDEX idx_homework_teacher ON homework(teacher_id)'); + + $this->addSql(<<<'SQL' + CREATE TABLE homework_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + homework_id UUID NOT NULL REFERENCES homework(id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size INT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $this->addSql('CREATE INDEX idx_homework_attachments_homework ON homework_attachments(homework_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS homework_attachments'); + $this->addSql('DROP TABLE IF EXISTS homework'); + } +} diff --git a/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkCommand.php b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkCommand.php new file mode 100644 index 0000000..f3a4547 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkCommand.php @@ -0,0 +1,19 @@ +tenantId); + $classId = ClassId::fromString($command->classId); + $subjectId = SubjectId::fromString($command->subjectId); + $teacherId = UserId::fromString($command->teacherId); + $now = $this->clock->now(); + + if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) { + throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId); + } + + $calendar = $this->calendarProvider->forCurrentYear($tenantId); + $dueDate = new DateTimeImmutable($command->dueDate); + $this->dueDateValidator->valider($dueDate, $now, $calendar); + + $homework = Homework::creer( + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: $command->title, + description: $command->description, + dueDate: $dueDate, + now: $now, + ); + + $this->homeworkRepository->save($homework); + + return $homework; + } +} diff --git a/backend/src/Scolarite/Application/Command/DeleteHomework/DeleteHomeworkCommand.php b/backend/src/Scolarite/Application/Command/DeleteHomework/DeleteHomeworkCommand.php new file mode 100644 index 0000000..9755bae --- /dev/null +++ b/backend/src/Scolarite/Application/Command/DeleteHomework/DeleteHomeworkCommand.php @@ -0,0 +1,15 @@ +tenantId); + $homeworkId = HomeworkId::fromString($command->homeworkId); + + $homework = $this->homeworkRepository->get($homeworkId, $tenantId); + + $teacherId = UserId::fromString($command->teacherId); + if ((string) $homework->teacherId !== (string) $teacherId) { + throw NonProprietaireDuDevoirException::withId($homeworkId); + } + + $homework->supprimer($this->clock->now()); + + $this->homeworkRepository->save($homework); + + return $homework; + } +} diff --git a/backend/src/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkCommand.php b/backend/src/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkCommand.php new file mode 100644 index 0000000..c5e588b --- /dev/null +++ b/backend/src/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkCommand.php @@ -0,0 +1,18 @@ +tenantId); + $homeworkId = HomeworkId::fromString($command->homeworkId); + $now = $this->clock->now(); + + $homework = $this->homeworkRepository->get($homeworkId, $tenantId); + + $teacherId = UserId::fromString($command->teacherId); + if ((string) $homework->teacherId !== (string) $teacherId) { + throw NonProprietaireDuDevoirException::withId($homeworkId); + } + + $calendar = $this->calendarProvider->forCurrentYear($tenantId); + $dueDate = new DateTimeImmutable($command->dueDate); + $this->dueDateValidator->valider($dueDate, $now, $calendar); + + $homework->modifier( + title: $command->title, + description: $command->description, + dueDate: $dueDate, + now: $now, + ); + + $this->homeworkRepository->save($homework); + + return $homework; + } +} diff --git a/backend/src/Scolarite/Application/Command/UploadHomeworkAttachment/UploadHomeworkAttachmentCommand.php b/backend/src/Scolarite/Application/Command/UploadHomeworkAttachment/UploadHomeworkAttachmentCommand.php new file mode 100644 index 0000000..e9c77d3 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/UploadHomeworkAttachment/UploadHomeworkAttachmentCommand.php @@ -0,0 +1,18 @@ +tenantId); + $homeworkId = HomeworkId::fromString($command->homeworkId); + + // Valider que le devoir existe + $this->homeworkRepository->get($homeworkId, $tenantId); + + $attachmentId = HomeworkAttachmentId::generate(); + $storagePath = sprintf('homework/%s/%s/%s', $command->tenantId, $command->homeworkId, $command->filename); + + $content = file_get_contents($command->tempFilePath); + + if ($content === false) { + throw PieceJointeInvalideException::lectureFichierImpossible($command->filename); + } + + $this->fileStorage->upload($storagePath, $content, $command->mimeType); + + return new HomeworkAttachment( + id: $attachmentId, + filename: $command->filename, + filePath: $storagePath, + fileSize: $command->fileSize, + mimeType: $command->mimeType, + uploadedAt: $this->clock->now(), + ); + } +} diff --git a/backend/src/Scolarite/Application/Port/FileStorage.php b/backend/src/Scolarite/Application/Port/FileStorage.php new file mode 100644 index 0000000..7f78916 --- /dev/null +++ b/backend/src/Scolarite/Application/Port/FileStorage.php @@ -0,0 +1,15 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->homeworkId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Event/DevoirModifie.php b/backend/src/Scolarite/Domain/Event/DevoirModifie.php new file mode 100644 index 0000000..123aee3 --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/DevoirModifie.php @@ -0,0 +1,34 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->homeworkId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Event/DevoirSupprime.php b/backend/src/Scolarite/Domain/Event/DevoirSupprime.php new file mode 100644 index 0000000..fceaf71 --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/DevoirSupprime.php @@ -0,0 +1,32 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->homeworkId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Exception/DateEcheanceInvalideException.php b/backend/src/Scolarite/Domain/Exception/DateEcheanceInvalideException.php new file mode 100644 index 0000000..22b5740 --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/DateEcheanceInvalideException.php @@ -0,0 +1,30 @@ +format('Y-m-d'), + )); + } + + public static function jourBloque(DateTimeImmutable $dueDate, string $raison): self + { + return new self(sprintf( + 'La date d\'échéance "%s" est bloquée : %s.', + $dueDate->format('Y-m-d'), + $raison, + )); + } +} diff --git a/backend/src/Scolarite/Domain/Exception/DevoirDejaSupprimeException.php b/backend/src/Scolarite/Domain/Exception/DevoirDejaSupprimeException.php new file mode 100644 index 0000000..265ea89 --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/DevoirDejaSupprimeException.php @@ -0,0 +1,21 @@ +updatedAt = $createdAt; + } + + public static function creer( + TenantId $tenantId, + ClassId $classId, + SubjectId $subjectId, + UserId $teacherId, + string $title, + ?string $description, + DateTimeImmutable $dueDate, + DateTimeImmutable $now, + ): self { + $homework = new self( + id: HomeworkId::generate(), + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: $title, + description: $description, + dueDate: $dueDate, + status: HomeworkStatus::PUBLISHED, + createdAt: $now, + ); + + $homework->recordEvent(new DevoirCree( + homeworkId: $homework->id, + classId: (string) $classId, + subjectId: (string) $subjectId, + teacherId: (string) $teacherId, + title: $title, + dueDate: $dueDate, + occurredOn: $now, + )); + + return $homework; + } + + public function modifier( + string $title, + ?string $description, + DateTimeImmutable $dueDate, + DateTimeImmutable $now, + ): void { + if ($this->status === HomeworkStatus::DELETED) { + throw DevoirDejaSupprimeException::withId($this->id); + } + + $this->title = $title; + $this->description = $description; + $this->dueDate = $dueDate; + $this->updatedAt = $now; + + $this->recordEvent(new DevoirModifie( + homeworkId: $this->id, + title: $title, + dueDate: $dueDate, + occurredOn: $now, + )); + } + + public function supprimer(DateTimeImmutable $now): void + { + if ($this->status === HomeworkStatus::DELETED) { + throw DevoirDejaSupprimeException::withId($this->id); + } + + $this->status = HomeworkStatus::DELETED; + $this->updatedAt = $now; + + $this->recordEvent(new DevoirSupprime( + homeworkId: $this->id, + occurredOn: $now, + )); + } + + /** + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + HomeworkId $id, + TenantId $tenantId, + ClassId $classId, + SubjectId $subjectId, + UserId $teacherId, + string $title, + ?string $description, + DateTimeImmutable $dueDate, + HomeworkStatus $status, + DateTimeImmutable $createdAt, + DateTimeImmutable $updatedAt, + ): self { + $homework = new self( + id: $id, + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: $title, + description: $description, + dueDate: $dueDate, + status: $status, + createdAt: $createdAt, + ); + + $homework->updatedAt = $updatedAt; + + return $homework; + } +} diff --git a/backend/src/Scolarite/Domain/Model/Homework/HomeworkAttachment.php b/backend/src/Scolarite/Domain/Model/Homework/HomeworkAttachment.php new file mode 100644 index 0000000..798fab3 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Homework/HomeworkAttachment.php @@ -0,0 +1,45 @@ + self::MAX_FILE_SIZE) { + throw PieceJointeInvalideException::fichierTropGros($fileSize, self::MAX_FILE_SIZE); + } + $this->fileSize = $fileSize; + } + }, + public private(set) string $mimeType { + set(string $mimeType) { + if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { + throw PieceJointeInvalideException::typeFichierNonAutorise($mimeType); + } + $this->mimeType = $mimeType; + } + }, + public private(set) DateTimeImmutable $uploadedAt, + ) { + } +} diff --git a/backend/src/Scolarite/Domain/Model/Homework/HomeworkAttachmentId.php b/backend/src/Scolarite/Domain/Model/Homework/HomeworkAttachmentId.php new file mode 100644 index 0000000..e5ae0ce --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Homework/HomeworkAttachmentId.php @@ -0,0 +1,11 @@ + */ + public function findByTeacher(UserId $teacherId, TenantId $tenantId): array; + + /** @return array */ + public function findByClass(ClassId $classId, TenantId $tenantId): array; + + public function delete(HomeworkId $id, TenantId $tenantId): void; +} diff --git a/backend/src/Scolarite/Domain/Service/DueDateValidator.php b/backend/src/Scolarite/Domain/Service/DueDateValidator.php new file mode 100644 index 0000000..bfd6ffc --- /dev/null +++ b/backend/src/Scolarite/Domain/Service/DueDateValidator.php @@ -0,0 +1,28 @@ +modify('+1 day')->setTime(0, 0); + + if ($dueDate < $tomorrow) { + throw DateEcheanceInvalideException::dansLePasse($dueDate); + } + + if (!$calendar->estJourOuvre($dueDate)) { + $entry = $calendar->trouverEntreePourDate($dueDate); + $raison = $entry !== null ? $entry->label : 'weekend'; + + throw DateEcheanceInvalideException::jourBloque($dueDate, $raison); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php new file mode 100644 index 0000000..1f64cd9 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php @@ -0,0 +1,76 @@ + + */ +final readonly class CreateHomeworkProcessor implements ProcessorInterface +{ + public function __construct( + private CreateHomeworkHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param HomeworkResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + try { + $command = new CreateHomeworkCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + classId: $data->classId ?? '', + subjectId: $data->subjectId ?? '', + teacherId: $user->userId(), + title: $data->title ?? '', + description: $data->description, + dueDate: $data->dueDate ?? '', + ); + + $homework = ($this->handler)($command); + + foreach ($homework->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return HomeworkResource::fromDomain($homework); + } catch (EnseignantNonAffecteException|DateEcheanceInvalideException $e) { + throw new BadRequestHttpException($e->getMessage()); + } catch (InvalidUuidStringException $e) { + throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/DeleteHomeworkProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/DeleteHomeworkProcessor.php new file mode 100644 index 0000000..4605a9c --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/DeleteHomeworkProcessor.php @@ -0,0 +1,79 @@ + + */ +final readonly class DeleteHomeworkProcessor implements ProcessorInterface +{ + public function __construct( + private DeleteHomeworkHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param HomeworkResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + /** @var string $id */ + $id = $uriVariables['id']; + + try { + $command = new DeleteHomeworkCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + homeworkId: $id, + teacherId: $user->userId(), + ); + + $homework = ($this->handler)($command); + + foreach ($homework->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return HomeworkResource::fromDomain($homework); + } catch (HomeworkNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (NonProprietaireDuDevoirException $e) { + throw new AccessDeniedHttpException($e->getMessage()); + } catch (DevoirDejaSupprimeException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/UpdateHomeworkProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/UpdateHomeworkProcessor.php new file mode 100644 index 0000000..fd1a147 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/UpdateHomeworkProcessor.php @@ -0,0 +1,83 @@ + + */ +final readonly class UpdateHomeworkProcessor implements ProcessorInterface +{ + public function __construct( + private UpdateHomeworkHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param HomeworkResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + /** @var string $id */ + $id = $uriVariables['id']; + + try { + $command = new UpdateHomeworkCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + homeworkId: $id, + teacherId: $user->userId(), + title: $data->title ?? '', + description: $data->description, + dueDate: $data->dueDate ?? '', + ); + + $homework = ($this->handler)($command); + + foreach ($homework->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return HomeworkResource::fromDomain($homework); + } catch (HomeworkNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (NonProprietaireDuDevoirException $e) { + throw new AccessDeniedHttpException($e->getMessage()); + } catch (DateEcheanceInvalideException|DevoirDejaSupprimeException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php new file mode 100644 index 0000000..ee89598 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php @@ -0,0 +1,77 @@ + + */ +final readonly class HomeworkCollectionProvider implements ProviderInterface +{ + public function __construct( + private HomeworkRepository $homeworkRepository, + private TenantContext $tenantContext, + private Security $security, + private ClassRepository $classRepository, + private SubjectRepository $subjectRepository, + ) { + } + + /** @return array */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + $tenantId = $this->tenantContext->getCurrentTenantId(); + $teacherId = UserId::fromString($user->userId()); + + $homeworks = $this->homeworkRepository->findByTeacher($teacherId, $tenantId); + + return array_map(fn (Homework $homework) => HomeworkResource::fromDomain( + $homework, + $this->resolveClassName($homework), + $this->resolveSubjectName($homework), + ), $homeworks); + } + + private function resolveClassName(Homework $homework): ?string + { + $class = $this->classRepository->findById($homework->classId); + + return $class !== null ? (string) $class->name : null; + } + + private function resolveSubjectName(Homework $homework): ?string + { + $subject = $this->subjectRepository->findById($homework->subjectId); + + return $subject !== null ? (string) $subject->name : null; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkItemProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkItemProvider.php new file mode 100644 index 0000000..dbc4fd3 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkItemProvider.php @@ -0,0 +1,74 @@ + + */ +final readonly class HomeworkItemProvider implements ProviderInterface +{ + public function __construct( + private HomeworkRepository $homeworkRepository, + private TenantContext $tenantContext, + private Security $security, + private ClassRepository $classRepository, + private SubjectRepository $subjectRepository, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + /** @var string $id */ + $id = $uriVariables['id']; + + $homework = $this->homeworkRepository->findById( + HomeworkId::fromString($id), + $this->tenantContext->getCurrentTenantId(), + ); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + if ((string) $homework->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Vous n\'êtes pas le propriétaire de ce devoir.'); + } + + $class = $this->classRepository->findById($homework->classId); + $subject = $this->subjectRepository->findById($homework->subjectId); + + return HomeworkResource::fromDomain( + $homework, + $class !== null ? (string) $class->name : null, + $subject !== null ? (string) $subject->name : null, + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php new file mode 100644 index 0000000..ecf6839 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php @@ -0,0 +1,112 @@ + ['Default', 'create']], + name: 'create_homework', + ), + new Patch( + uriTemplate: '/homework/{id}', + provider: HomeworkItemProvider::class, + processor: UpdateHomeworkProcessor::class, + validationContext: ['groups' => ['Default', 'update']], + name: 'update_homework', + ), + new Delete( + uriTemplate: '/homework/{id}', + provider: HomeworkItemProvider::class, + processor: DeleteHomeworkProcessor::class, + name: 'delete_homework', + ), + ], +)] +final class HomeworkResource +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + #[Assert\NotBlank(message: 'La classe est requise.', groups: ['create'])] + #[Assert\Uuid(message: 'L\'identifiant de la classe doit être un UUID valide.', groups: ['create'])] + public ?string $classId = null; + + #[Assert\NotBlank(message: 'La matière est requise.', groups: ['create'])] + #[Assert\Uuid(message: 'L\'identifiant de la matière doit être un UUID valide.', groups: ['create'])] + public ?string $subjectId = null; + + public ?string $teacherId = null; + + #[Assert\NotBlank(message: 'Le titre est requis.', groups: ['create', 'update'])] + #[Assert\Length(max: 255, maxMessage: 'Le titre ne peut pas dépasser 255 caractères.')] + public ?string $title = null; + + public ?string $description = null; + + #[Assert\NotBlank(message: 'La date d\'échéance est requise.', groups: ['create', 'update'])] + public ?string $dueDate = null; + + public ?string $status = null; + + public ?string $className = null; + + public ?string $subjectName = null; + + public ?DateTimeImmutable $createdAt = null; + + public ?DateTimeImmutable $updatedAt = null; + + public static function fromDomain( + Homework $homework, + ?string $className = null, + ?string $subjectName = null, + ): self { + $resource = new self(); + $resource->id = (string) $homework->id; + $resource->classId = (string) $homework->classId; + $resource->subjectId = (string) $homework->subjectId; + $resource->teacherId = (string) $homework->teacherId; + $resource->title = $homework->title; + $resource->description = $homework->description; + $resource->dueDate = $homework->dueDate->format('Y-m-d'); + $resource->status = $homework->status->value; + $resource->className = $className; + $resource->subjectName = $subjectName; + $resource->createdAt = $homework->createdAt; + $resource->updatedAt = $homework->updatedAt; + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Messaging/OnDevoirCreeHandler.php b/backend/src/Scolarite/Infrastructure/Messaging/OnDevoirCreeHandler.php new file mode 100644 index 0000000..8362bfa --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Messaging/OnDevoirCreeHandler.php @@ -0,0 +1,35 @@ +logger->info('Nouveau devoir créé — notification prévue', [ + 'homework_id' => (string) $event->homeworkId, + 'class_id' => $event->classId, + 'subject_id' => $event->subjectId, + 'teacher_id' => $event->teacherId, + 'title' => $event->title, + 'due_date' => $event->dueDate->format('Y-m-d'), + ]); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Messaging/OnDevoirModifieHandler.php b/backend/src/Scolarite/Infrastructure/Messaging/OnDevoirModifieHandler.php new file mode 100644 index 0000000..96639b1 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Messaging/OnDevoirModifieHandler.php @@ -0,0 +1,32 @@ +logger->info('Devoir modifié — notification prévue', [ + 'homework_id' => (string) $event->homeworkId, + 'title' => $event->title, + 'due_date' => $event->dueDate->format('Y-m-d'), + ]); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRepository.php new file mode 100644 index 0000000..19e29d8 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRepository.php @@ -0,0 +1,172 @@ +connection->executeStatement( + 'INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) + VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :due_date, :status, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + due_date = EXCLUDED.due_date, + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $homework->id, + 'tenant_id' => (string) $homework->tenantId, + 'class_id' => (string) $homework->classId, + 'subject_id' => (string) $homework->subjectId, + 'teacher_id' => (string) $homework->teacherId, + 'title' => $homework->title, + 'description' => $homework->description, + 'due_date' => $homework->dueDate->format('Y-m-d'), + 'status' => $homework->status->value, + 'created_at' => $homework->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $homework->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function get(HomeworkId $id, TenantId $tenantId): Homework + { + $homework = $this->findById($id, $tenantId); + + if ($homework === null) { + throw HomeworkNotFoundException::withId($id); + } + + return $homework; + } + + #[Override] + public function findById(HomeworkId $id, TenantId $tenantId): ?Homework + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM homework WHERE id = :id AND tenant_id = :tenant_id', + ['id' => (string) $id, 'tenant_id' => (string) $tenantId], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findByTeacher(UserId $teacherId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM homework + WHERE teacher_id = :teacher_id + AND tenant_id = :tenant_id + AND status != :deleted + ORDER BY due_date ASC', + [ + 'teacher_id' => (string) $teacherId, + 'tenant_id' => (string) $tenantId, + 'deleted' => HomeworkStatus::DELETED->value, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function findByClass(ClassId $classId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM homework + WHERE class_id = :class_id + AND tenant_id = :tenant_id + AND status != :deleted + ORDER BY due_date ASC', + [ + 'class_id' => (string) $classId, + 'tenant_id' => (string) $tenantId, + 'deleted' => HomeworkStatus::DELETED->value, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function delete(HomeworkId $id, TenantId $tenantId): void + { + $this->connection->executeStatement( + 'DELETE FROM homework WHERE id = :id AND tenant_id = :tenant_id', + ['id' => (string) $id, 'tenant_id' => (string) $tenantId], + ); + } + + /** @param array $row */ + private function hydrate(array $row): Homework + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $classId */ + $classId = $row['class_id']; + /** @var string $subjectId */ + $subjectId = $row['subject_id']; + /** @var string $teacherId */ + $teacherId = $row['teacher_id']; + /** @var string $title */ + $title = $row['title']; + /** @var string|null $description */ + $description = $row['description']; + /** @var string $dueDate */ + $dueDate = $row['due_date']; + /** @var string $status */ + $status = $row['status']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return Homework::reconstitute( + id: HomeworkId::fromString($id), + tenantId: TenantId::fromString($tenantId), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString($subjectId), + teacherId: UserId::fromString($teacherId), + title: $title, + description: $description, + dueDate: new DateTimeImmutable($dueDate), + status: HomeworkStatus::from($status), + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkRepository.php new file mode 100644 index 0000000..3afdc43 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkRepository.php @@ -0,0 +1,87 @@ + */ + private array $byId = []; + + #[Override] + public function save(Homework $homework): void + { + $this->byId[(string) $homework->id] = $homework; + } + + #[Override] + public function get(HomeworkId $id, TenantId $tenantId): Homework + { + $homework = $this->findById($id, $tenantId); + + if ($homework === null) { + throw HomeworkNotFoundException::withId($id); + } + + return $homework; + } + + #[Override] + public function findById(HomeworkId $id, TenantId $tenantId): ?Homework + { + $homework = $this->byId[(string) $id] ?? null; + + if ($homework === null || !$homework->tenantId->equals($tenantId)) { + return null; + } + + return $homework; + } + + #[Override] + public function findByTeacher(UserId $teacherId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (Homework $h): bool => $h->teacherId->equals($teacherId) + && $h->tenantId->equals($tenantId) + && $h->status !== HomeworkStatus::DELETED, + )); + } + + #[Override] + public function findByClass(ClassId $classId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (Homework $h): bool => $h->classId->equals($classId) + && $h->tenantId->equals($tenantId) + && $h->status !== HomeworkStatus::DELETED, + )); + } + + #[Override] + public function delete(HomeworkId $id, TenantId $tenantId): void + { + $key = (string) $id; + + if (isset($this->byId[$key]) && $this->byId[$key]->tenantId->equals($tenantId)) { + unset($this->byId[$key]); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Storage/LocalFileStorage.php b/backend/src/Scolarite/Infrastructure/Storage/LocalFileStorage.php new file mode 100644 index 0000000..ffe2296 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Storage/LocalFileStorage.php @@ -0,0 +1,53 @@ +storagePath . '/' . $path; + $dir = dirname($fullPath); + + if (!is_dir($dir)) { + mkdir($dir, 0o755, true); + } + + $data = is_string($content) ? $content : ''; + + file_put_contents($fullPath, $data); + + return $path; + } + + #[Override] + public function delete(string $path): void + { + $fullPath = $this->storagePath . '/' . $path; + + if (is_file($fullPath)) { + unlink($fullPath); + } + } +} diff --git a/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php b/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php new file mode 100644 index 0000000..6d71999 --- /dev/null +++ b/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php @@ -0,0 +1,428 @@ +createFixtures(); + } + + protected function tearDown(): void + { + $container = static::getContainer(); + /** @var Connection $connection */ + $connection = $container->get(Connection::class); + $connection->executeStatement('DELETE FROM homework WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM school_classes WHERE id = :id', ['id' => self::CLASS_ID]); + $connection->executeStatement('DELETE FROM subjects WHERE id = :id', ['id' => self::SUBJECT_ID]); + $connection->executeStatement('DELETE FROM users WHERE id IN (:o, :t)', ['o' => self::OWNER_TEACHER_ID, 't' => self::OTHER_TEACHER_ID]); + + parent::tearDown(); + } + + private function createFixtures(): void + { + $container = static::getContainer(); + /** @var Connection $connection */ + $connection = $container->get(Connection::class); + + $schoolId = '550e8400-e29b-41d4-a716-ff6655440001'; + $academicYearId = '550e8400-e29b-41d4-a716-ff6655440002'; + + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'owner-hw@test.local', '', 'Owner', 'Teacher', '[\"ROLE_PROF\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::OWNER_TEACHER_ID, 'tid' => self::TENANT_ID], + ); + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'other-hw@test.local', '', 'Other', 'Teacher', '[\"ROLE_PROF\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::OTHER_TEACHER_ID, 'tid' => self::TENANT_ID], + ); + $connection->executeStatement( + "INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at) + VALUES (:id, :tid, :sid, :ayid, 'Test-HW-Class', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId], + ); + $connection->executeStatement( + "INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) + VALUES (:id, :tid, :sid, 'Test-HW-Subject', 'THW', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], + ); + } + + // ========================================================================= + // Security - Without tenant (404) + // ========================================================================= + + #[Test] + public function getHomeworkListReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('GET', '/api/homework', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function createHomeworkReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('POST', '/api/homework', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'classId' => self::CLASS_ID, + 'subjectId' => self::SUBJECT_ID, + 'title' => 'Devoir de maths', + 'dueDate' => '2026-06-15', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function updateHomeworkReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('PATCH', '/api/homework/' . self::HOMEWORK_ID, [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/merge-patch+json', + ], + 'json' => [ + 'title' => 'Titre modifié', + 'dueDate' => '2026-06-20', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function deleteHomeworkReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('DELETE', '/api/homework/' . self::HOMEWORK_ID, [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // Security - Without authentication (401) + // ========================================================================= + + #[Test] + public function getHomeworkListReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/homework', [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function createHomeworkReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'classId' => self::CLASS_ID, + 'subjectId' => self::SUBJECT_ID, + 'title' => 'Devoir de maths', + 'dueDate' => '2026-06-15', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function updateHomeworkReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('PATCH', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID, [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/merge-patch+json', + ], + 'json' => [ + 'title' => 'Titre modifié', + 'dueDate' => '2026-06-20', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function deleteHomeworkReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('DELETE', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID, [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + // ========================================================================= + // 5.1-FUNC-005 (P1) - POST /homework with empty title -> 422 + // ========================================================================= + + #[Test] + public function createHomeworkWithEmptyTitleReturns422(): void + { + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'classId' => self::CLASS_ID, + 'subjectId' => self::SUBJECT_ID, + 'title' => '', + 'dueDate' => '2026-06-15', + ], + ]); + + self::assertResponseStatusCodeSame(422); + } + + // ========================================================================= + // 5.1-FUNC-006 (P1) - POST /homework with past due date -> 400 + // ========================================================================= + + #[Test] + public function createHomeworkWithPastDueDateReturns400(): void + { + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'classId' => self::CLASS_ID, + 'subjectId' => self::SUBJECT_ID, + 'title' => 'Devoir de maths', + 'dueDate' => '2020-01-01', + ], + ]); + + self::assertResponseStatusCodeSame(400); + } + + // ========================================================================= + // 5.1-FUNC-007 (P0) - PATCH /homework/{id} by non-owner -> 403 + // ========================================================================= + + #[Test] + public function updateHomeworkByNonOwnerReturns403(): void + { + $this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED); + + $client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('PATCH', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID, [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/merge-patch+json', + ], + 'json' => [ + 'title' => 'Titre piraté', + 'dueDate' => '2026-06-20', + ], + ]); + + self::assertResponseStatusCodeSame(403); + } + + // ========================================================================= + // 5.1-FUNC-008 (P1) - PATCH /homework/{id} on deleted homework -> 400 + // ========================================================================= + + #[Test] + public function updateDeletedHomeworkReturns400(): void + { + $this->persistHomework(self::DELETED_HOMEWORK_ID, HomeworkStatus::DELETED); + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('PATCH', 'http://ecole-alpha.classeo.local/api/homework/' . self::DELETED_HOMEWORK_ID, [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/merge-patch+json', + ], + 'json' => [ + 'title' => 'Titre modifié', + 'dueDate' => '2026-06-20', + ], + ]); + + self::assertResponseStatusCodeSame(400); + } + + // ========================================================================= + // 5.1-FUNC-009 (P0) - DELETE /homework/{id} by non-owner -> 403 + // ========================================================================= + + #[Test] + public function deleteHomeworkByNonOwnerReturns403(): void + { + $this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED); + + $client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('DELETE', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID, [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(403); + } + + // ========================================================================= + // 5.1-FUNC-010 (P1) - DELETE /homework/{id} on already deleted -> 400 + // ========================================================================= + + #[Test] + public function deleteAlreadyDeletedHomeworkReturns400(): void + { + $this->persistHomework(self::DELETED_HOMEWORK_ID, HomeworkStatus::DELETED); + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('DELETE', 'http://ecole-alpha.classeo.local/api/homework/' . self::DELETED_HOMEWORK_ID, [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(400); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private function createAuthenticatedClient(string $userId, array $roles): \ApiPlatform\Symfony\Bundle\Test\Client + { + $client = static::createClient(); + + $user = new SecurityUser( + userId: UserId::fromString($userId), + email: 'teacher@classeo.local', + hashedPassword: '', + tenantId: TenantId::fromString(self::TENANT_ID), + roles: $roles, + ); + + $client->loginUser($user, 'api'); + + return $client; + } + + private function persistHomework(string $homeworkId, HomeworkStatus $status): void + { + $now = new DateTimeImmutable(); + + $homework = Homework::reconstitute( + id: HomeworkId::fromString($homeworkId), + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::OWNER_TEACHER_ID), + title: 'Devoir existant', + description: null, + dueDate: new DateTimeImmutable('2026-06-15'), + status: $status, + createdAt: $now, + updatedAt: $now, + ); + + /** @var HomeworkRepository $repository */ + $repository = static::getContainer()->get(HomeworkRepository::class); + $repository->save($homework); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php new file mode 100644 index 0000000..2d22a31 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php @@ -0,0 +1,157 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-12 10:00:00'); + } + }; + } + + #[Test] + public function itCreatesHomeworkSuccessfully(): void + { + $handler = $this->createHandler(affecte: true); + $command = $this->createCommand(); + + $homework = $handler($command); + + self::assertNotEmpty((string) $homework->id); + self::assertSame(HomeworkStatus::PUBLISHED, $homework->status); + self::assertSame('Exercices chapitre 5', $homework->title); + } + + #[Test] + public function itPersistsHomeworkInRepository(): void + { + $handler = $this->createHandler(affecte: true); + $command = $this->createCommand(); + + $created = $handler($command); + + $homework = $this->homeworkRepository->get( + HomeworkId::fromString((string) $created->id), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertSame('Exercices chapitre 5', $homework->title); + } + + #[Test] + public function itThrowsWhenTeacherNotAffected(): void + { + $handler = $this->createHandler(affecte: false); + + $this->expectException(EnseignantNonAffecteException::class); + + $handler($this->createCommand()); + } + + #[Test] + public function itThrowsWhenDueDateIsInvalid(): void + { + $handler = $this->createHandler(affecte: true); + + $this->expectException(DateEcheanceInvalideException::class); + + $handler($this->createCommand(dueDate: '2026-03-11')); + } + + #[Test] + public function itAllowsNullDescription(): void + { + $handler = $this->createHandler(affecte: true); + $command = $this->createCommand(description: null); + + $homework = $handler($command); + + self::assertNull($homework->description); + } + + private function createHandler(bool $affecte): CreateHomeworkHandler + { + $affectationChecker = new class($affecte) implements EnseignantAffectationChecker { + public function __construct(private readonly bool $affecte) + { + } + + public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool + { + return $this->affecte; + } + }; + + $calendarProvider = new class implements CurrentCalendarProvider { + public function forCurrentYear(TenantId $tenantId): SchoolCalendar + { + return SchoolCalendar::reconstitute( + tenantId: $tenantId, + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'), + zone: null, + entries: [], + ); + } + }; + + return new CreateHomeworkHandler( + $this->homeworkRepository, + $affectationChecker, + $calendarProvider, + new DueDateValidator(), + $this->clock, + ); + } + + private function createCommand( + ?string $dueDate = null, + mixed $description = 'Faire les exercices 1 à 10', + ): CreateHomeworkCommand { + return new CreateHomeworkCommand( + tenantId: self::TENANT_ID, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + title: 'Exercices chapitre 5', + description: $description, + dueDate: $dueDate ?? '2026-04-15', + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/DeleteHomework/DeleteHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/DeleteHomework/DeleteHomeworkHandlerTest.php new file mode 100644 index 0000000..14e7035 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/DeleteHomework/DeleteHomeworkHandlerTest.php @@ -0,0 +1,130 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-12 10:00:00'); + } + }; + + $this->seedHomework(); + } + + #[Test] + public function itDeletesHomeworkSuccessfully(): void + { + $handler = $this->createHandler(); + + $homework = $handler(new DeleteHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->existingHomeworkId, + teacherId: '550e8400-e29b-41d4-a716-446655440010', + )); + + self::assertSame(HomeworkStatus::DELETED, $homework->status); + } + + #[Test] + public function itThrowsWhenHomeworkNotFound(): void + { + $handler = $this->createHandler(); + + $this->expectException(HomeworkNotFoundException::class); + + $handler(new DeleteHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) HomeworkId::generate(), + teacherId: '550e8400-e29b-41d4-a716-446655440010', + )); + } + + #[Test] + public function itThrowsWhenHomeworkAlreadyDeleted(): void + { + $homework = $this->homeworkRepository->get($this->existingHomeworkId, TenantId::fromString(self::TENANT_ID)); + $homework->supprimer(new DateTimeImmutable('2026-03-12')); + $this->homeworkRepository->save($homework); + + $handler = $this->createHandler(); + + $this->expectException(DevoirDejaSupprimeException::class); + + $handler(new DeleteHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->existingHomeworkId, + teacherId: '550e8400-e29b-41d4-a716-446655440010', + )); + } + + #[Test] + public function itThrowsWhenNotOwner(): void + { + $handler = $this->createHandler(); + + $this->expectException(NonProprietaireDuDevoirException::class); + + $handler(new DeleteHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->existingHomeworkId, + teacherId: '550e8400-e29b-41d4-a716-446655440099', + )); + } + + private function seedHomework(): void + { + $homework = Homework::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'), + title: 'Exercices', + description: 'Description', + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-10 10:00:00'), + ); + + $this->existingHomeworkId = $homework->id; + $this->homeworkRepository->save($homework); + } + + private function createHandler(): DeleteHomeworkHandler + { + return new DeleteHomeworkHandler( + $this->homeworkRepository, + $this->clock, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandlerTest.php new file mode 100644 index 0000000..7cd6167 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandlerTest.php @@ -0,0 +1,179 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-12 10:00:00'); + } + }; + + $this->seedHomework(); + } + + #[Test] + public function itUpdatesHomeworkSuccessfully(): void + { + $handler = $this->createHandler(); + $command = new UpdateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->existingHomeworkId, + teacherId: '550e8400-e29b-41d4-a716-446655440010', + title: 'Titre modifié', + description: 'Nouvelle description', + dueDate: '2026-04-20', + ); + + $homework = $handler($command); + + self::assertSame('Titre modifié', $homework->title); + self::assertSame('Nouvelle description', $homework->description); + } + + #[Test] + public function itThrowsWhenHomeworkNotFound(): void + { + $handler = $this->createHandler(); + + $this->expectException(HomeworkNotFoundException::class); + + $handler(new UpdateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) HomeworkId::generate(), + teacherId: '550e8400-e29b-41d4-a716-446655440010', + title: 'Test', + description: null, + dueDate: '2026-04-20', + )); + } + + #[Test] + public function itThrowsWhenDueDateInvalid(): void + { + $handler = $this->createHandler(); + + $this->expectException(DateEcheanceInvalideException::class); + + $handler(new UpdateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->existingHomeworkId, + teacherId: '550e8400-e29b-41d4-a716-446655440010', + title: 'Test', + description: null, + dueDate: '2026-03-11', + )); + } + + #[Test] + public function itThrowsWhenHomeworkDeleted(): void + { + $homework = $this->homeworkRepository->get($this->existingHomeworkId, TenantId::fromString(self::TENANT_ID)); + $homework->supprimer(new DateTimeImmutable('2026-03-12')); + $this->homeworkRepository->save($homework); + + $handler = $this->createHandler(); + + $this->expectException(DevoirDejaSupprimeException::class); + + $handler(new UpdateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->existingHomeworkId, + teacherId: '550e8400-e29b-41d4-a716-446655440010', + title: 'Test', + description: null, + dueDate: '2026-04-20', + )); + } + + #[Test] + public function itThrowsWhenNotOwner(): void + { + $handler = $this->createHandler(); + + $this->expectException(NonProprietaireDuDevoirException::class); + + $handler(new UpdateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->existingHomeworkId, + teacherId: '550e8400-e29b-41d4-a716-446655440099', + title: 'Test', + description: null, + dueDate: '2026-04-20', + )); + } + + private function seedHomework(): void + { + $homework = Homework::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'), + title: 'Exercices', + description: 'Description', + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-10 10:00:00'), + ); + + $this->existingHomeworkId = $homework->id; + $this->homeworkRepository->save($homework); + } + + private function createHandler(): UpdateHomeworkHandler + { + $calendarProvider = new class implements CurrentCalendarProvider { + public function forCurrentYear(TenantId $tenantId): SchoolCalendar + { + return SchoolCalendar::reconstitute( + tenantId: $tenantId, + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'), + zone: null, + entries: [], + ); + } + }; + + return new UpdateHomeworkHandler( + $this->homeworkRepository, + $calendarProvider, + new DueDateValidator(), + $this->clock, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkAttachmentTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkAttachmentTest.php new file mode 100644 index 0000000..5948177 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkAttachmentTest.php @@ -0,0 +1,106 @@ +id->equals($id)); + self::assertSame('exercices.pdf', $attachment->filename); + self::assertSame('homework/abc123/exercices.pdf', $attachment->filePath); + self::assertSame(500_000, $attachment->fileSize); + self::assertSame('application/pdf', $attachment->mimeType); + self::assertEquals($uploadedAt, $attachment->uploadedAt); + } + + #[Test] + public function acceptsJpegMimeType(): void + { + $attachment = $this->createAttachmentWithMimeType('image/jpeg'); + + self::assertSame('image/jpeg', $attachment->mimeType); + } + + #[Test] + public function acceptsPngMimeType(): void + { + $attachment = $this->createAttachmentWithMimeType('image/png'); + + self::assertSame('image/png', $attachment->mimeType); + } + + #[Test] + public function rejectsInvalidMimeType(): void + { + $this->expectException(PieceJointeInvalideException::class); + $this->expectExceptionMessageMatches('/text\/plain/'); + + $this->createAttachmentWithMimeType('text/plain'); + } + + #[Test] + public function rejectsFileTooLarge(): void + { + $this->expectException(PieceJointeInvalideException::class); + $this->expectExceptionMessageMatches('/taille maximum/'); + + new HomeworkAttachment( + id: HomeworkAttachmentId::generate(), + filename: 'big.pdf', + filePath: 'homework/abc/big.pdf', + fileSize: 11 * 1024 * 1024, // 11 Mo + mimeType: 'application/pdf', + uploadedAt: new DateTimeImmutable(), + ); + } + + #[Test] + public function acceptsExactMaxSize(): void + { + $attachment = new HomeworkAttachment( + id: HomeworkAttachmentId::generate(), + filename: 'max.pdf', + filePath: 'homework/abc/max.pdf', + fileSize: 10 * 1024 * 1024, // Exactement 10 Mo + mimeType: 'application/pdf', + uploadedAt: new DateTimeImmutable(), + ); + + self::assertSame(10 * 1024 * 1024, $attachment->fileSize); + } + + private function createAttachmentWithMimeType(string $mimeType): HomeworkAttachment + { + return new HomeworkAttachment( + id: HomeworkAttachmentId::generate(), + filename: 'test.file', + filePath: 'homework/abc/test.file', + fileSize: 1000, + mimeType: $mimeType, + uploadedAt: new DateTimeImmutable(), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkTest.php new file mode 100644 index 0000000..cd13500 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkTest.php @@ -0,0 +1,223 @@ +createHomework(); + + self::assertSame(HomeworkStatus::PUBLISHED, $homework->status); + } + + #[Test] + public function creerRecordsDevoirCreeEvent(): void + { + $homework = $this->createHomework(); + + $events = $homework->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(DevoirCree::class, $events[0]); + self::assertSame($homework->id, $events[0]->homeworkId); + } + + #[Test] + public function creerSetsAllProperties(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $classId = ClassId::fromString(self::CLASS_ID); + $subjectId = SubjectId::fromString(self::SUBJECT_ID); + $teacherId = UserId::fromString(self::TEACHER_ID); + $dueDate = new DateTimeImmutable('2026-04-15'); + $now = new DateTimeImmutable('2026-03-12 10:00:00'); + + $homework = Homework::creer( + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: 'Exercices chapitre 5', + description: 'Faire les exercices 1 à 10', + dueDate: $dueDate, + now: $now, + ); + + self::assertTrue($homework->tenantId->equals($tenantId)); + self::assertTrue($homework->classId->equals($classId)); + self::assertTrue($homework->subjectId->equals($subjectId)); + self::assertTrue($homework->teacherId->equals($teacherId)); + self::assertSame('Exercices chapitre 5', $homework->title); + self::assertSame('Faire les exercices 1 à 10', $homework->description); + self::assertEquals($dueDate, $homework->dueDate); + self::assertSame(HomeworkStatus::PUBLISHED, $homework->status); + self::assertEquals($now, $homework->createdAt); + self::assertEquals($now, $homework->updatedAt); + } + + #[Test] + public function creerAllowsNullDescription(): void + { + $homework = Homework::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Devoir sans description', + description: null, + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + + self::assertNull($homework->description); + } + + #[Test] + public function modifierUpdatesFieldsAndRecordsEvent(): void + { + $homework = $this->createHomework(); + $homework->pullDomainEvents(); + $modifiedAt = new DateTimeImmutable('2026-03-13 14:00:00'); + $newDueDate = new DateTimeImmutable('2026-04-20'); + + $homework->modifier( + title: 'Titre modifié', + description: 'Nouvelle description', + dueDate: $newDueDate, + now: $modifiedAt, + ); + + self::assertSame('Titre modifié', $homework->title); + self::assertSame('Nouvelle description', $homework->description); + self::assertEquals($newDueDate, $homework->dueDate); + self::assertEquals($modifiedAt, $homework->updatedAt); + + $events = $homework->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(DevoirModifie::class, $events[0]); + self::assertSame($homework->id, $events[0]->homeworkId); + } + + #[Test] + public function modifierThrowsWhenDeleted(): void + { + $homework = $this->createHomework(); + $homework->supprimer(new DateTimeImmutable('2026-03-13')); + + $this->expectException(DevoirDejaSupprimeException::class); + + $homework->modifier( + title: 'Titre', + description: null, + dueDate: new DateTimeImmutable('2026-04-20'), + now: new DateTimeImmutable('2026-03-14'), + ); + } + + #[Test] + public function supprimerChangesStatusAndRecordsEvent(): void + { + $homework = $this->createHomework(); + $homework->pullDomainEvents(); + $deletedAt = new DateTimeImmutable('2026-03-14 08:00:00'); + + $homework->supprimer($deletedAt); + + self::assertSame(HomeworkStatus::DELETED, $homework->status); + self::assertEquals($deletedAt, $homework->updatedAt); + + $events = $homework->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(DevoirSupprime::class, $events[0]); + self::assertSame($homework->id, $events[0]->homeworkId); + } + + #[Test] + public function supprimerThrowsWhenAlreadyDeleted(): void + { + $homework = $this->createHomework(); + $homework->supprimer(new DateTimeImmutable('2026-03-14')); + + $this->expectException(DevoirDejaSupprimeException::class); + + $homework->supprimer(new DateTimeImmutable('2026-03-15')); + } + + #[Test] + public function reconstituteRestoresAllPropertiesWithoutEvents(): void + { + $id = HomeworkId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $classId = ClassId::fromString(self::CLASS_ID); + $subjectId = SubjectId::fromString(self::SUBJECT_ID); + $teacherId = UserId::fromString(self::TEACHER_ID); + $dueDate = new DateTimeImmutable('2026-04-15'); + $createdAt = new DateTimeImmutable('2026-03-12 10:00:00'); + $updatedAt = new DateTimeImmutable('2026-03-13 14:00:00'); + + $homework = Homework::reconstitute( + id: $id, + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: 'Exercices chapitre 5', + description: 'Faire les exercices', + dueDate: $dueDate, + status: HomeworkStatus::PUBLISHED, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + self::assertTrue($homework->id->equals($id)); + self::assertTrue($homework->tenantId->equals($tenantId)); + self::assertTrue($homework->classId->equals($classId)); + self::assertTrue($homework->subjectId->equals($subjectId)); + self::assertTrue($homework->teacherId->equals($teacherId)); + self::assertSame('Exercices chapitre 5', $homework->title); + self::assertSame('Faire les exercices', $homework->description); + self::assertEquals($dueDate, $homework->dueDate); + self::assertSame(HomeworkStatus::PUBLISHED, $homework->status); + self::assertEquals($createdAt, $homework->createdAt); + self::assertEquals($updatedAt, $homework->updatedAt); + self::assertEmpty($homework->pullDomainEvents()); + } + + private function createHomework(): Homework + { + return Homework::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Exercices chapitre 5', + description: 'Faire les exercices 1 à 10', + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Service/DueDateValidatorTest.php b/backend/tests/Unit/Scolarite/Domain/Service/DueDateValidatorTest.php new file mode 100644 index 0000000..36be74f --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Service/DueDateValidatorTest.php @@ -0,0 +1,138 @@ +validator = new DueDateValidator(); + } + + #[Test] + public function acceptsValidFutureSchoolDay(): void + { + $now = new DateTimeImmutable('2026-03-12 10:00:00'); // Jeudi + $dueDate = new DateTimeImmutable('2026-03-16'); // Lundi + $calendar = $this->createEmptyCalendar(); + + $this->validator->valider($dueDate, $now, $calendar); + + $this->addToAssertionCount(1); + } + + #[Test] + public function rejectsPastDate(): void + { + $now = new DateTimeImmutable('2026-03-12 10:00:00'); + $dueDate = new DateTimeImmutable('2026-03-11'); + $calendar = $this->createEmptyCalendar(); + + $this->expectException(DateEcheanceInvalideException::class); + $this->expectExceptionMessageMatches('/futur/'); + + $this->validator->valider($dueDate, $now, $calendar); + } + + #[Test] + public function rejectsTodayDate(): void + { + $now = new DateTimeImmutable('2026-03-12 10:00:00'); + $dueDate = new DateTimeImmutable('2026-03-12'); + $calendar = $this->createEmptyCalendar(); + + $this->expectException(DateEcheanceInvalideException::class); + $this->expectExceptionMessageMatches('/futur/'); + + $this->validator->valider($dueDate, $now, $calendar); + } + + #[Test] + public function acceptsTomorrowDate(): void + { + $now = new DateTimeImmutable('2026-03-12 10:00:00'); // Jeudi + $dueDate = new DateTimeImmutable('2026-03-13'); // Vendredi + $calendar = $this->createEmptyCalendar(); + + $this->validator->valider($dueDate, $now, $calendar); + + $this->addToAssertionCount(1); + } + + #[Test] + public function rejectsWeekendDate(): void + { + $now = new DateTimeImmutable('2026-03-12 10:00:00'); + $dueDate = new DateTimeImmutable('2026-03-14'); // Samedi + $calendar = $this->createEmptyCalendar(); + + $this->expectException(DateEcheanceInvalideException::class); + $this->expectExceptionMessageMatches('/weekend/'); + + $this->validator->valider($dueDate, $now, $calendar); + } + + #[Test] + public function rejectsHolidayDate(): void + { + $now = new DateTimeImmutable('2026-03-12 10:00:00'); + $dueDate = new DateTimeImmutable('2026-04-06'); // Lundi + $calendar = $this->createCalendarWithVacation( + new DateTimeImmutable('2026-04-04'), + new DateTimeImmutable('2026-04-19'), + 'Vacances de printemps', + ); + + $this->expectException(DateEcheanceInvalideException::class); + $this->expectExceptionMessageMatches('/Vacances de printemps/'); + + $this->validator->valider($dueDate, $now, $calendar); + } + + private function createEmptyCalendar(): SchoolCalendar + { + return SchoolCalendar::reconstitute( + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'), + zone: null, + entries: [], + ); + } + + private function createCalendarWithVacation( + DateTimeImmutable $start, + DateTimeImmutable $end, + string $label, + ): SchoolCalendar { + return SchoolCalendar::reconstitute( + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'), + zone: null, + entries: [ + new CalendarEntry( + id: CalendarEntryId::generate(), + type: CalendarEntryType::VACATION, + startDate: $start, + endDate: $end, + label: $label, + ), + ], + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Storage/InMemoryFileStorage.php b/backend/tests/Unit/Scolarite/Infrastructure/Storage/InMemoryFileStorage.php new file mode 100644 index 0000000..890d6d9 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/Storage/InMemoryFileStorage.php @@ -0,0 +1,41 @@ + */ + private array $files = []; + + #[Override] + public function upload(string $path, mixed $content, string $mimeType): string + { + $this->files[$path] = is_string($content) ? $content : ''; + + return $path; + } + + #[Override] + public function delete(string $path): void + { + unset($this->files[$path]); + } + + public function has(string $path): bool + { + return isset($this->files[$path]); + } + + public function get(string $path): ?string + { + return $this->files[$path] ?? null; + } +} diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index ca128ea..a19aeb1 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -174,14 +174,15 @@ test.describe('Dashboard', () => { await goToDashboard(page); await switchToDemoRole(page, 'Enseignant'); - // Action cards should be disabled since hasRealData=false + // First two action cards should be disabled since hasRealData=false + // "Créer un devoir" navigates to homework page and is always enabled const actionCards = page.locator('.action-card'); const count = await actionCards.count(); expect(count).toBeGreaterThanOrEqual(3); - for (let i = 0; i < count; i++) { - await expect(actionCards.nth(i)).toBeDisabled(); - } + await expect(actionCards.nth(0)).toBeDisabled(); + await expect(actionCards.nth(1)).toBeDisabled(); + await expect(actionCards.nth(2)).toBeEnabled(); }); test('shows placeholder sections for teacher data', async ({ page }) => { diff --git a/frontend/e2e/homework.spec.ts b/frontend/e2e/homework.spec.ts new file mode 100644 index 0000000..fcc8153 --- /dev/null +++ b/frontend/e2e/homework.spec.ts @@ -0,0 +1,450 @@ +import { test, expect } 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 TEACHER_EMAIL = 'e2e-homework-teacher@example.com'; +const TEACHER_PASSWORD = 'HomeworkTest123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +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! }; +} + +/** + * Returns a future weekday date string (YYYY-MM-DD). + * Skips weekends to satisfy DueDateValidator. + */ +function getNextWeekday(daysFromNow: number): string { + const date = new Date(); + date.setDate(date.getDate() + daysFromNow); + // Skip to Monday if weekend + const day = date.getDay(); + if (day === 0) date.setDate(date.getDate() + 1); // Sunday → Monday + if (day === 6) date.setDate(date.getDate() + 2); // Saturday → Monday + // Use local date components to avoid UTC timezone shift from toISOString() + 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 loginAsTeacher(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TEACHER_EMAIL); + await page.locator('#password').fill(TEACHER_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function navigateToHomework(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`); + await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 }); +} + +function seedTeacherAssignments() { + const { academicYearId } = resolveDeterministicIds(); + try { + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.tenant_id = '${TENANT_ID}' ` + + `AND s.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + } catch { + // Table may not exist + } +} + +test.describe('Homework Management (Story 5.1)', () => { + test.beforeAll(async () => { + // Create teacher user + 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' } + ); + + // Ensure class and subject exist + const { schoolId, academicYearId } = resolveDeterministicIds(); + 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-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + clearCache(); + }); + + test.beforeEach(async () => { + // Clean up homework data + try { + runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`); + } catch { + // Table may not exist + } + + // Re-ensure data exists + const { schoolId, academicYearId } = resolveDeterministicIds(); + 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-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + clearCache(); + }); + + // ============================================================================ + // Navigation + // ============================================================================ + test.describe('Navigation', () => { + test('homework link appears in teacher navigation', async ({ page }) => { + await loginAsTeacher(page); + const nav = page.locator('.desktop-nav'); + await expect(nav.getByRole('link', { name: /devoirs/i })).toBeVisible({ timeout: 15000 }); + }); + + test('can navigate to homework page', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible(); + }); + }); + + // ============================================================================ + // Empty State + // ============================================================================ + test.describe('Empty State', () => { + test('shows empty state when no homework exists', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await expect(page.getByText(/aucun devoir/i)).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC1: Create Homework + // ============================================================================ + test.describe('AC1: Create Homework', () => { + test('can create a new homework', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + // Open create modal + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + // Select class + const classSelect = page.locator('#hw-class'); + await expect(classSelect).toBeVisible(); + await classSelect.selectOption({ index: 1 }); + + // Select subject (AC2: filtered by class) + const subjectSelect = page.locator('#hw-subject'); + await expect(subjectSelect).toBeEnabled(); + await subjectSelect.selectOption({ index: 1 }); + + // Fill title + await page.locator('#hw-title').fill('Exercices chapitre 5'); + + // Fill description + await page.locator('#hw-description').fill('Pages 42-45, exercices 1 à 10'); + + // Set due date (next weekday, at least 2 days from now) + await page.locator('#hw-due-date').fill(getNextWeekday(3)); + + // Submit + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + // Modal closes and homework appears in list + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Exercices chapitre 5')).toBeVisible({ timeout: 10000 }); + }); + + test('cancel closes the modal without creating', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + await page.getByRole('button', { name: /annuler/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); + }); + }); + + // ============================================================================ + // AC2: Subject Filtering + // ============================================================================ + test.describe('AC2: Subject Filtering', () => { + test('subject dropdown is disabled until class is selected', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + // Subject should be disabled initially + await expect(page.locator('#hw-subject')).toBeDisabled(); + + // Select class + await page.locator('#hw-class').selectOption({ index: 1 }); + + // Subject should now be enabled + await expect(page.locator('#hw-subject')).toBeEnabled(); + }); + }); + + // ============================================================================ + // AC5: Published Homework Appears in List + // ============================================================================ + test.describe('AC5: Published Homework', () => { + test('created homework appears in list with correct info', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + // Create a homework + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir de mathématiques'); + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Verify homework card shows + await expect(page.getByText('Devoir de mathématiques')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/publié/i)).toBeVisible(); + }); + }); + + // ============================================================================ + // AC6: Edit Homework + // ============================================================================ + test.describe('AC6: Edit Homework', () => { + test('can modify an existing homework', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + // First create a homework + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir à modifier'); + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Devoir à modifier')).toBeVisible({ timeout: 10000 }); + + // Click edit + await page.getByRole('button', { name: /modifier/i }).first().click(); + const editDialog = page.getByRole('dialog'); + await expect(editDialog).toBeVisible({ timeout: 10000 }); + + // Change title + await page.locator('#edit-title').clear(); + await page.locator('#edit-title').fill('Devoir modifié'); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + await expect(editDialog).not.toBeVisible({ timeout: 10000 }); + + // Verify updated title + await expect(page.getByText('Devoir modifié')).toBeVisible({ timeout: 10000 }); + }); + + test('can delete an existing homework', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + // First create a homework + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir à supprimer'); + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Devoir à supprimer')).toBeVisible({ timeout: 10000 }); + + // Click delete + await page.getByRole('button', { name: /supprimer/i }).first().click(); + + // Confirm in alertdialog + const confirmDialog = page.getByRole('alertdialog'); + await expect(confirmDialog).toBeVisible({ timeout: 5000 }); + await expect(page.getByText(/êtes-vous sûr/i)).toBeVisible(); + + await confirmDialog.getByRole('button', { name: /supprimer/i }).click(); + await expect(confirmDialog).not.toBeVisible({ timeout: 10000 }); + + // Homework should be gone from the list (or show as deleted) + await expect(page.getByText('Devoir à supprimer')).not.toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // Date Validation + // ============================================================================ + test.describe('Date Validation', () => { + test('due date input has min attribute set to tomorrow', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + const dueDateInput = page.locator('#hw-due-date'); + const minValue = await dueDateInput.getAttribute('min'); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const ey = tomorrow.getFullYear(); + const em = String(tomorrow.getMonth() + 1).padStart(2, '0'); + const ed = String(tomorrow.getDate()).padStart(2, '0'); + expect(minValue).toBe(`${ey}-${em}-${ed}`); + }); + + test('backend rejects a past due date', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Devoir date passée'); + await page.locator('#hw-description').fill('Test validation'); + + // Set a past date — fill() works with Svelte 5 bind:value + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const y = yesterday.getFullYear(); + const m = String(yesterday.getMonth() + 1).padStart(2, '0'); + const d = String(yesterday.getDate()).padStart(2, '0'); + const pastDate = `${y}-${m}-${d}`; + await page.locator('#hw-due-date').fill(pastDate); + + // Bypass HTML native validation (min attribute) to test backend validation + await page.locator('form.modal-body').evaluate((el) => el.setAttribute('novalidate', '')); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + // Backend should reject — an error alert appears + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // Partial Update + // ============================================================================ + test.describe('Partial Update', () => { + test('can update only the title without changing other fields', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + + const dueDate = getNextWeekday(5); + + // Create a homework with description + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill('Titre original'); + await page.locator('#hw-description').fill('Description inchangée'); + await page.locator('#hw-due-date').fill(dueDate); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Titre original')).toBeVisible({ timeout: 10000 }); + + // Open edit modal + await page.getByRole('button', { name: /modifier/i }).first().click(); + const editDialog = page.getByRole('dialog'); + await expect(editDialog).toBeVisible({ timeout: 10000 }); + + // Verify pre-filled values + await expect(page.locator('#edit-description')).toHaveValue('Description inchangée'); + await expect(page.locator('#edit-due-date')).toHaveValue(dueDate); + + // Change only the title + await page.locator('#edit-title').clear(); + await page.locator('#edit-title').fill('Titre mis à jour'); + + await page.getByRole('button', { name: /enregistrer/i }).click(); + await expect(editDialog).not.toBeVisible({ timeout: 10000 }); + + // Verify title changed + await expect(page.getByText('Titre mis à jour')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Titre original')).not.toBeVisible(); + + // Verify description still visible + await expect(page.getByText('Description inchangée')).toBeVisible(); + }); + }); +}); diff --git a/frontend/e2e/sessions.spec.ts b/frontend/e2e/sessions.spec.ts index d6780cf..9637295 100644 --- a/frontend/e2e/sessions.spec.ts +++ b/frontend/e2e/sessions.spec.ts @@ -48,8 +48,10 @@ async function login(page: import('@playwright/test').Page, email: string) { await page.goto(getTenantUrl('/login')); await page.locator('#email').fill(email); await page.locator('#password').fill(TEST_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await page.waitForURL(getTenantUrl('/dashboard'), { timeout: 30000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } test.describe('Sessions Management', () => { diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte index 72d3763..deab3cf 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardTeacher.svelte @@ -3,6 +3,7 @@ import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte'; import { getApiBaseUrl } from '$lib/api/config'; import { authenticatedFetch, isAuthenticated } from '$lib/auth'; + import { goto } from '$app/navigation'; import { untrack } from 'svelte'; let { @@ -78,7 +79,7 @@ 📝 Saisir des notes - diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 9d24cc2..215763b 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -14,6 +14,7 @@ let pathname = $derived(page.url.pathname); let isEleve = $derived(getActiveRole() === 'ROLE_ELEVE'); let isParent = $derived(getActiveRole() === 'ROLE_PARENT'); + let isProf = $derived(getActiveRole() === 'ROLE_PROF'); // Load user roles on mount for multi-role context switching (FR5) // Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode @@ -102,6 +103,9 @@