feat: Permettre aux enseignants de dupliquer un devoir vers plusieurs classes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Un enseignant qui donne le même travail à plusieurs classes devait
jusqu'ici recréer manuellement chaque devoir. La duplication permet
de sélectionner les classes cibles, d'ajuster les dates d'échéance
par classe, et de créer tous les devoirs en une seule opération
atomique (transaction).

La validation s'effectue par classe (affectation enseignant, date
d'échéance) avec un rapport d'erreurs détaillé. L'infrastructure
de warnings est prête pour les règles de timing de la Story 5.3.
Le filtrage par classe dans la liste des devoirs passe côté serveur
pour rester compatible avec la pagination.
This commit is contained in:
2026-03-15 14:20:48 +01:00
parent e9efb90f59
commit 68179a929f
18 changed files with 1831 additions and 2 deletions

View File

@@ -210,9 +210,15 @@ services:
App\Scolarite\Domain\Repository\HomeworkRepository: App\Scolarite\Domain\Repository\HomeworkRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRepository alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRepository
App\Scolarite\Domain\Repository\HomeworkAttachmentRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkAttachmentRepository
App\Scolarite\Domain\Service\DueDateValidator: App\Scolarite\Domain\Service\DueDateValidator:
autowire: true autowire: true
App\Scolarite\Domain\Service\HomeworkDuplicator:
autowire: true
App\Scolarite\Application\Port\FileStorage: App\Scolarite\Application\Port\FileStorage:
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DuplicateHomework;
final readonly class DuplicateHomeworkCommand
{
/**
* @param array<string> $targetClassIds
* @param array<string, string> $dueDates Clé = classId, valeur = date (Y-m-d)
*/
public function __construct(
public string $tenantId,
public string $homeworkId,
public string $teacherId,
public array $targetClassIds,
public array $dueDates = [],
) {
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DuplicateHomework;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
use App\Scolarite\Domain\Exception\DuplicationValidationException;
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Domain\Service\DueDateValidator;
use App\Scolarite\Domain\Service\HomeworkDuplicator;
use App\Scolarite\Domain\ValueObject\ClassValidationResult;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_unique;
use function array_values;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Exception;
use function in_array;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class DuplicateHomeworkHandler
{
public function __construct(
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
private EnseignantAffectationChecker $affectationChecker,
private CurrentCalendarProvider $calendarProvider,
private DueDateValidator $dueDateValidator,
private HomeworkDuplicator $duplicator,
private Clock $clock,
private Connection $connection,
) {
}
public function __invoke(DuplicateHomeworkCommand $command): DuplicationResult
{
$tenantId = TenantId::fromString($command->tenantId);
$homeworkId = HomeworkId::fromString($command->homeworkId);
$teacherId = UserId::fromString($command->teacherId);
$now = $this->clock->now();
$source = $this->homeworkRepository->get($homeworkId, $tenantId);
if ((string) $source->teacherId !== (string) $teacherId) {
throw NonProprietaireDuDevoirException::withId($homeworkId);
}
$calendar = $this->calendarProvider->forCurrentYear($tenantId);
// Dédupliquer et exclure la classe source
$uniqueClassIds = array_values(array_unique($command->targetClassIds));
$sourceClassIdStr = (string) $source->classId;
$targetClassIds = [];
$dueDates = [];
$validationResults = [];
$seen = [];
foreach ($uniqueClassIds as $classIdStr) {
if ($classIdStr === $sourceClassIdStr) {
continue;
}
if (in_array($classIdStr, $seen, true)) {
continue;
}
$seen[] = $classIdStr;
$classId = ClassId::fromString($classIdStr);
try {
$dueDate = isset($command->dueDates[$classIdStr])
? new DateTimeImmutable($command->dueDates[$classIdStr])
: $source->dueDate;
} catch (Exception) {
$validationResults[] = ClassValidationResult::failure(
$classId,
"Date d'échéance invalide : {$command->dueDates[$classIdStr]}.",
);
continue;
}
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $source->subjectId, $tenantId)) {
$validationResults[] = ClassValidationResult::failure(
$classId,
"L'enseignant n'est pas affecté à cette classe pour cette matière.",
);
continue;
}
try {
$this->dueDateValidator->valider($dueDate, $now, $calendar);
$validationResults[] = ClassValidationResult::success($classId);
$targetClassIds[] = $classId;
$dueDates[] = $dueDate;
} catch (DateEcheanceInvalideException $e) {
$validationResults[] = ClassValidationResult::failure($classId, $e->getMessage());
}
}
$failures = array_filter($validationResults, static fn (ClassValidationResult $r): bool => !$r->valid);
if ($failures !== []) {
throw DuplicationValidationException::withResults($validationResults);
}
$duplicates = $this->duplicator->dupliquer($source, $targetClassIds, $dueDates, $now);
$sourceAttachments = $this->attachmentRepository->findByHomeworkId($source->id);
$this->connection->beginTransaction();
try {
foreach ($duplicates as $duplicate) {
$this->homeworkRepository->save($duplicate);
foreach ($sourceAttachments as $attachment) {
$this->attachmentRepository->save(
$duplicate->id,
new HomeworkAttachment(
id: HomeworkAttachmentId::generate(),
filename: $attachment->filename,
filePath: $attachment->filePath,
fileSize: $attachment->fileSize,
mimeType: $attachment->mimeType,
uploadedAt: $now,
),
);
}
}
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
throw $e;
}
$warnings = array_values(array_filter(
$validationResults,
static fn (ClassValidationResult $r): bool => $r->valid && $r->warning !== null,
));
return new DuplicationResult($duplicates, $warnings);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DuplicateHomework;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\ValueObject\ClassValidationResult;
final readonly class DuplicationResult
{
/**
* @param array<Homework> $homeworks
* @param array<ClassValidationResult> $warnings
*/
public function __construct(
public array $homeworks,
public array $warnings,
) {
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\ValueObject\ClassValidationResult;
use function array_filter;
use function array_map;
use DomainException;
use function implode;
final class DuplicationValidationException extends DomainException
{
/** @param array<ClassValidationResult> $results */
private function __construct(
string $message,
public readonly array $results,
) {
parent::__construct($message);
}
/** @param array<ClassValidationResult> $results */
public static function withResults(array $results): self
{
$failures = array_filter($results, static fn (ClassValidationResult $r): bool => !$r->valid);
$messages = array_map(
static fn (ClassValidationResult $r): string => "Classe {$r->classId} : {$r->error}",
$failures,
);
return new self(
'Validation échouée pour certaines classes : ' . implode('; ', $messages),
$results,
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
interface HomeworkAttachmentRepository
{
/** @return array<HomeworkAttachment> */
public function findByHomeworkId(HomeworkId $homeworkId): array;
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Service;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Scolarite\Domain\Model\Homework\Homework;
use DateTimeImmutable;
final readonly class HomeworkDuplicator
{
/**
* @param array<ClassId> $targetClassIds
* @param array<DateTimeImmutable> $dueDates Dates indexées par position (même ordre que $targetClassIds)
*
* @return array<Homework>
*/
public function dupliquer(
Homework $source,
array $targetClassIds,
array $dueDates,
DateTimeImmutable $now,
): array {
$duplicates = [];
foreach ($targetClassIds as $index => $classId) {
$dueDate = $dueDates[$index] ?? $source->dueDate;
$duplicates[] = Homework::creer(
tenantId: $source->tenantId,
classId: $classId,
subjectId: $source->subjectId,
teacherId: $source->teacherId,
title: $source->title,
description: $source->description,
dueDate: $dueDate,
now: $now,
);
}
return $duplicates;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\ValueObject;
use App\Administration\Domain\Model\SchoolClass\ClassId;
final readonly class ClassValidationResult
{
private function __construct(
public ClassId $classId,
public bool $valid,
public ?string $error,
public ?string $warning,
) {
}
public static function success(ClassId $classId, ?string $warning = null): self
{
return new self($classId, true, null, $warning);
}
public static function failure(ClassId $classId, string $error): self
{
return new self($classId, false, $error, null);
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\DuplicateHomework\DuplicateHomeworkCommand;
use App\Scolarite\Application\Command\DuplicateHomework\DuplicateHomeworkHandler;
use App\Scolarite\Domain\Exception\DuplicationValidationException;
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\ValueObject\ClassValidationResult;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use function is_array;
use function is_string;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
final readonly class DuplicateHomeworkController
{
public function __construct(
private DuplicateHomeworkHandler $handler,
private TenantContext $tenantContext,
private Security $security,
private MessageBusInterface $eventBus,
) {
}
#[Route('/api/homework/{id}/duplicate', name: 'api_homework_duplicate', methods: ['POST'])]
public function __invoke(string $id, Request $request): JsonResponse
{
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.');
}
$body = $request->toArray();
$targetClassIds = $body['targetClassIds'] ?? null;
if (!is_array($targetClassIds) || $targetClassIds === []) {
throw new BadRequestHttpException('Au moins une classe cible est requise.');
}
/** @var array<string, string> $dueDates */
$dueDates = [];
$rawDueDates = $body['dueDates'] ?? [];
if (is_array($rawDueDates)) {
foreach ($rawDueDates as $classId => $date) {
if (is_string($classId) && is_string($date)) {
$dueDates[$classId] = $date;
}
}
}
$classIds = [];
foreach ($targetClassIds as $item) {
if (!is_string($item)) {
throw new BadRequestHttpException('Les identifiants de classe doivent être des chaînes.');
}
$classIds[] = $item;
}
try {
$command = new DuplicateHomeworkCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
homeworkId: $id,
teacherId: $user->userId(),
targetClassIds: $classIds,
dueDates: $dueDates,
);
$result = ($this->handler)($command);
foreach ($result->homeworks as $homework) {
foreach ($homework->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
}
$response = [
'data' => array_map(static fn (Homework $h): array => [
'id' => (string) $h->id,
'classId' => (string) $h->classId,
'title' => $h->title,
'dueDate' => $h->dueDate->format('Y-m-d'),
'status' => $h->status->value,
], $result->homeworks),
];
if ($result->warnings !== []) {
$response['warnings'] = array_map(static fn (ClassValidationResult $r): array => [
'classId' => (string) $r->classId,
'warning' => $r->warning,
], $result->warnings);
}
return new JsonResponse($response, Response::HTTP_CREATED);
} catch (HomeworkNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (NonProprietaireDuDevoirException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (DuplicationValidationException $e) {
return new JsonResponse([
'error' => $e->getMessage(),
'results' => array_map(static fn (ClassValidationResult $r): array => [
'classId' => (string) $r->classId,
'valid' => $r->valid,
'error' => $r->error,
], $e->results),
], Response::HTTP_BAD_REQUEST);
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage());
}
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository; use App\Administration\Domain\Repository\SubjectRepository;
@@ -15,10 +16,15 @@ use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource; use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
use App\Shared\Infrastructure\Tenant\TenantContext; use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_filter;
use function array_map; use function array_map;
use function array_values;
use function is_string;
use Override; use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/** /**
@@ -54,6 +60,23 @@ final readonly class HomeworkCollectionProvider implements ProviderInterface
$homeworks = $this->homeworkRepository->findByTeacher($teacherId, $tenantId); $homeworks = $this->homeworkRepository->findByTeacher($teacherId, $tenantId);
/** @var array<string, mixed> $filters */
$filters = $context['filters'] ?? [];
$classIdFilter = $filters['classId'] ?? null;
if (is_string($classIdFilter) && $classIdFilter !== '') {
try {
$filterClassId = ClassId::fromString($classIdFilter);
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('UUID de classe invalide : ' . $e->getMessage());
}
$homeworks = array_values(array_filter(
$homeworks,
static fn (Homework $h): bool => $h->classId->equals($filterClassId),
));
}
return array_map(fn (Homework $homework) => HomeworkResource::fromDomain( return array_map(fn (Homework $homework) => HomeworkResource::fromDomain(
$homework, $homework,
$this->resolveClassName($homework), $this->resolveClassName($homework),

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineHomeworkAttachmentRepository implements HomeworkAttachmentRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function findByHomeworkId(HomeworkId $homeworkId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM homework_attachments WHERE homework_id = :homework_id',
['homework_id' => (string) $homeworkId],
);
return array_map($this->hydrate(...), $rows);
}
#[Override]
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
{
$this->connection->executeStatement(
'INSERT INTO homework_attachments (id, homework_id, filename, file_path, file_size, mime_type, uploaded_at)
VALUES (:id, :homework_id, :filename, :file_path, :file_size, :mime_type, :uploaded_at)',
[
'id' => (string) $attachment->id,
'homework_id' => (string) $homeworkId,
'filename' => $attachment->filename,
'file_path' => $attachment->filePath,
'file_size' => $attachment->fileSize,
'mime_type' => $attachment->mimeType,
'uploaded_at' => $attachment->uploadedAt->format(DateTimeImmutable::ATOM),
],
);
}
/** @param array<string, mixed> $row */
private function hydrate(array $row): HomeworkAttachment
{
/** @var string $id */
$id = $row['id'];
/** @var string $filename */
$filename = $row['filename'];
/** @var string $filePath */
$filePath = $row['file_path'];
/** @var string|int $rawFileSize */
$rawFileSize = $row['file_size'];
$fileSize = (int) $rawFileSize;
/** @var string $mimeType */
$mimeType = $row['mime_type'];
/** @var string $uploadedAt */
$uploadedAt = $row['uploaded_at'];
return new HomeworkAttachment(
id: HomeworkAttachmentId::fromString($id),
filename: $filename,
filePath: $filePath,
fileSize: $fileSize,
mimeType: $mimeType,
uploadedAt: new DateTimeImmutable($uploadedAt),
);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use Override;
final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRepository
{
/** @var array<string, array<HomeworkAttachment>> */
private array $byHomeworkId = [];
#[Override]
public function findByHomeworkId(HomeworkId $homeworkId): array
{
return $this->byHomeworkId[(string) $homeworkId] ?? [];
}
#[Override]
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
{
$this->byHomeworkId[(string) $homeworkId][] = $attachment;
}
}

View File

@@ -39,6 +39,7 @@ final class HomeworkEndpointsTest extends ApiTestCase
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-886655440001'; private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-886655440001';
private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-996655440001'; private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-996655440001';
private const string DELETED_HOMEWORK_ID = '550e8400-e29b-41d4-a716-aa6655440001'; private const string DELETED_HOMEWORK_ID = '550e8400-e29b-41d4-a716-aa6655440001';
private const string TARGET_CLASS_ID = '550e8400-e29b-41d4-a716-776655440002';
protected function setUp(): void protected function setUp(): void
{ {
@@ -52,7 +53,7 @@ final class HomeworkEndpointsTest extends ApiTestCase
/** @var Connection $connection */ /** @var Connection $connection */
$connection = $container->get(Connection::class); $connection = $container->get(Connection::class);
$connection->executeStatement('DELETE FROM homework WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); $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 school_classes WHERE id IN (:id1, :id2)', ['id1' => self::CLASS_ID, 'id2' => self::TARGET_CLASS_ID]);
$connection->executeStatement('DELETE FROM subjects WHERE id = :id', ['id' => self::SUBJECT_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]); $connection->executeStatement('DELETE FROM users WHERE id IN (:o, :t)', ['o' => self::OWNER_TEACHER_ID, 't' => self::OTHER_TEACHER_ID]);
@@ -92,6 +93,12 @@ final class HomeworkEndpointsTest extends ApiTestCase
ON CONFLICT (id) DO NOTHING", ON CONFLICT (id) DO NOTHING",
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], ['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
); );
$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-Target-Class', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => self::TARGET_CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId],
);
} }
// ========================================================================= // =========================================================================
@@ -382,6 +389,123 @@ final class HomeworkEndpointsTest extends ApiTestCase
self::assertResponseStatusCodeSame(400); self::assertResponseStatusCodeSame(400);
} }
// =========================================================================
// 5.2-FUNC-001 (P1) - POST /homework/{id}/duplicate without tenant -> 404
// =========================================================================
#[Test]
public function duplicateHomeworkReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('POST', '/api/homework/' . self::HOMEWORK_ID . '/duplicate', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'targetClassIds' => [self::TARGET_CLASS_ID],
],
]);
self::assertResponseStatusCodeSame(404);
}
// =========================================================================
// 5.2-FUNC-002 (P1) - POST /homework/{id}/duplicate without auth -> 401
// =========================================================================
#[Test]
public function duplicateHomeworkReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID . '/duplicate', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'targetClassIds' => [self::TARGET_CLASS_ID],
],
]);
self::assertResponseStatusCodeSame(401);
}
// =========================================================================
// 5.2-FUNC-003 (P1) - POST /homework/{id}/duplicate empty classes -> 400
// =========================================================================
#[Test]
public function duplicateHomeworkReturns400WithEmptyTargetClasses(): void
{
$this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED);
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID . '/duplicate', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'targetClassIds' => [],
],
]);
self::assertResponseStatusCodeSame(400);
}
// =========================================================================
// 5.2-FUNC-004 (P1) - POST /homework/{id}/duplicate not found -> 404
// =========================================================================
#[Test]
public function duplicateHomeworkReturns404WhenHomeworkNotFound(): void
{
$nonExistentId = '00000000-0000-0000-0000-000000000099';
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . $nonExistentId . '/duplicate', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'targetClassIds' => [self::TARGET_CLASS_ID],
],
]);
self::assertResponseStatusCodeSame(404);
}
// =========================================================================
// 5.2-FUNC-005 (P1) - POST /homework/{id}/duplicate non-owner -> 404
// =========================================================================
#[Test]
public function duplicateHomeworkReturns404WhenTeacherNotOwner(): void
{
$this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED);
$client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']);
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID . '/duplicate', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'json' => [
'targetClassIds' => [self::TARGET_CLASS_ID],
],
]);
self::assertResponseStatusCodeSame(404);
}
// ========================================================================= // =========================================================================
// Helpers // Helpers
// ========================================================================= // =========================================================================

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\DuplicateHomework;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\DuplicateHomework\DuplicateHomeworkCommand;
use App\Scolarite\Application\Command\DuplicateHomework\DuplicateHomeworkHandler;
use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Domain\Exception\DuplicationValidationException;
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
use App\Scolarite\Domain\Model\Homework\HomeworkStatus;
use App\Scolarite\Domain\Service\DueDateValidator;
use App\Scolarite\Domain\Service\HomeworkDuplicator;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkAttachmentRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DuplicateHomeworkHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string OTHER_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440099';
private const string TARGET_CLASS_1 = '550e8400-e29b-41d4-a716-446655440021';
private const string TARGET_CLASS_2 = '550e8400-e29b-41d4-a716-446655440022';
private InMemoryHomeworkRepository $homeworkRepository;
private InMemoryHomeworkAttachmentRepository $attachmentRepository;
private Clock $clock;
protected function setUp(): void
{
$this->homeworkRepository = new InMemoryHomeworkRepository();
$this->attachmentRepository = new InMemoryHomeworkAttachmentRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-12 10:00:00');
}
};
}
#[Test]
public function itDuplicatesHomeworkForOneClass(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: true);
$command = new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1],
);
$result = $handler($command);
self::assertCount(1, $result->homeworks);
self::assertSame($source->title, $result->homeworks[0]->title);
self::assertSame($source->description, $result->homeworks[0]->description);
self::assertTrue($result->homeworks[0]->classId->equals(ClassId::fromString(self::TARGET_CLASS_1)));
self::assertSame(HomeworkStatus::PUBLISHED, $result->homeworks[0]->status);
}
#[Test]
public function itDuplicatesHomeworkForMultipleClasses(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: true);
$command = new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
);
$result = $handler($command);
self::assertCount(2, $result->homeworks);
self::assertTrue($result->homeworks[0]->classId->equals(ClassId::fromString(self::TARGET_CLASS_1)));
self::assertTrue($result->homeworks[1]->classId->equals(ClassId::fromString(self::TARGET_CLASS_2)));
}
#[Test]
public function itPersistsDuplicatesInRepository(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: true);
$command = new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1],
);
$result = $handler($command);
$found = $this->homeworkRepository->get(
$result->homeworks[0]->id,
TenantId::fromString(self::TENANT_ID),
);
self::assertSame($source->title, $found->title);
}
#[Test]
public function itAllowsCustomDueDatePerClass(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: true);
$command = new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
dueDates: [
self::TARGET_CLASS_1 => '2026-04-20',
self::TARGET_CLASS_2 => '2026-04-21',
],
);
$result = $handler($command);
self::assertSame('2026-04-20', $result->homeworks[0]->dueDate->format('Y-m-d'));
self::assertSame('2026-04-21', $result->homeworks[1]->dueDate->format('Y-m-d'));
}
#[Test]
public function itUsesSourceDueDateWhenNotCustomized(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: true);
$command = new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1],
);
$result = $handler($command);
self::assertSame($source->dueDate->format('Y-m-d'), $result->homeworks[0]->dueDate->format('Y-m-d'));
}
#[Test]
public function itThrowsWhenHomeworkNotFound(): void
{
$handler = $this->createHandler(affecte: true);
$this->expectException(HomeworkNotFoundException::class);
$handler(new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: '550e8400-e29b-41d4-a716-446655440099',
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1],
));
}
#[Test]
public function itThrowsWhenTeacherIsNotOwner(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: true);
$this->expectException(NonProprietaireDuDevoirException::class);
$handler(new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::OTHER_TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1],
));
}
#[Test]
public function itThrowsWhenTeacherNotAffectedToTargetClass(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: false);
$this->expectException(DuplicationValidationException::class);
$handler(new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1],
));
}
#[Test]
public function itThrowsWithPerClassValidationResults(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: false);
try {
$handler(new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
));
self::fail('Expected DuplicationValidationException');
} catch (DuplicationValidationException $e) {
self::assertCount(2, $e->results);
self::assertFalse($e->results[0]->valid);
self::assertFalse($e->results[1]->valid);
}
}
#[Test]
public function itDuplicatesAttachmentsForEachCopy(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: true);
$attachment = new HomeworkAttachment(
id: HomeworkAttachmentId::generate(),
filename: 'exercice.pdf',
filePath: 'homework/tenant/source/exercice.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
uploadedAt: new DateTimeImmutable('2026-03-12'),
);
$this->attachmentRepository->save($source->id, $attachment);
$command = new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
);
$result = $handler($command);
foreach ($result->homeworks as $duplicate) {
$attachments = $this->attachmentRepository->findByHomeworkId($duplicate->id);
self::assertCount(1, $attachments);
self::assertSame('exercice.pdf', $attachments[0]->filename);
self::assertSame('homework/tenant/source/exercice.pdf', $attachments[0]->filePath);
self::assertFalse($attachments[0]->id->equals($attachment->id));
}
}
#[Test]
public function itCreatesIndependentDuplicates(): void
{
$source = $this->createAndSaveHomework();
$handler = $this->createHandler(affecte: true);
$command = new DuplicateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $source->id,
teacherId: self::TEACHER_ID,
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
);
$result = $handler($command);
self::assertFalse($result->homeworks[0]->id->equals($source->id));
self::assertFalse($result->homeworks[1]->id->equals($source->id));
self::assertFalse($result->homeworks[0]->id->equals($result->homeworks[1]->id));
}
private function createAndSaveHomework(): Homework
{
$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: 'Exercices chapitre 5',
description: 'Faire les exercices 1 à 10',
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-10 10:00:00'),
);
$homework->pullDomainEvents();
$this->homeworkRepository->save($homework);
return $homework;
}
private function createHandler(bool $affecte): DuplicateHomeworkHandler
{
$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: [],
);
}
};
$connection = $this->createMock(Connection::class);
$connection->method('beginTransaction');
$connection->method('commit');
$connection->method('rollBack');
return new DuplicateHomeworkHandler(
$this->homeworkRepository,
$this->attachmentRepository,
$affectationChecker,
$calendarProvider,
new DueDateValidator(),
new HomeworkDuplicator(),
$this->clock,
$connection,
);
}
}

View File

@@ -121,6 +121,57 @@ final class UpdateHomeworkHandlerTest extends TestCase
)); ));
} }
#[Test]
public function itUpdatesTitleOnly(): void
{
$handler = $this->createHandler();
$command = new UpdateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $this->existingHomeworkId,
teacherId: '550e8400-e29b-41d4-a716-446655440010',
title: 'Nouveau titre',
description: null,
dueDate: '2026-04-15',
);
$homework = $handler($command);
self::assertSame('Nouveau titre', $homework->title);
self::assertNull($homework->description);
self::assertSame('2026-04-15', $homework->dueDate->format('Y-m-d'));
}
#[Test]
public function itUpdatesDescriptionFromNullToText(): void
{
$homeworkWithoutDescription = 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 sans description',
description: null,
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-10 10:00:00'),
);
$this->homeworkRepository->save($homeworkWithoutDescription);
$handler = $this->createHandler();
$command = new UpdateHomeworkCommand(
tenantId: self::TENANT_ID,
homeworkId: (string) $homeworkWithoutDescription->id,
teacherId: '550e8400-e29b-41d4-a716-446655440010',
title: 'Exercices sans description',
description: 'Nouvelle description ajoutée',
dueDate: '2026-04-15',
);
$homework = $handler($command);
self::assertSame('Nouvelle description ajoutée', $homework->description);
self::assertSame('Exercices sans description', $homework->title);
}
#[Test] #[Test]
public function itThrowsWhenNotOwner(): void public function itThrowsWhenNotOwner(): void
{ {

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Service;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Service\HomeworkDuplicator;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class HomeworkDuplicatorTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string TARGET_CLASS_1 = '550e8400-e29b-41d4-a716-446655440021';
private const string TARGET_CLASS_2 = '550e8400-e29b-41d4-a716-446655440022';
private HomeworkDuplicator $duplicator;
protected function setUp(): void
{
$this->duplicator = new HomeworkDuplicator();
}
#[Test]
public function itDuplicatesHomeworkForOneTargetClass(): void
{
$source = $this->createSourceHomework();
$now = new DateTimeImmutable('2026-03-14 10:00:00');
$targetClassId = ClassId::fromString(self::TARGET_CLASS_1);
$dueDate = new DateTimeImmutable('2026-04-20');
$duplicates = $this->duplicator->dupliquer($source, [$targetClassId], [$dueDate], $now);
self::assertCount(1, $duplicates);
$duplicate = $duplicates[0];
self::assertSame($source->title, $duplicate->title);
self::assertSame($source->description, $duplicate->description);
self::assertTrue($duplicate->classId->equals($targetClassId));
self::assertTrue($duplicate->subjectId->equals($source->subjectId));
self::assertTrue($duplicate->teacherId->equals($source->teacherId));
self::assertTrue($duplicate->tenantId->equals($source->tenantId));
self::assertSame('2026-04-20', $duplicate->dueDate->format('Y-m-d'));
}
#[Test]
public function itDuplicatesHomeworkForMultipleTargetClasses(): void
{
$source = $this->createSourceHomework();
$now = new DateTimeImmutable('2026-03-14 10:00:00');
$targetClasses = [
ClassId::fromString(self::TARGET_CLASS_1),
ClassId::fromString(self::TARGET_CLASS_2),
];
$dueDates = [
new DateTimeImmutable('2026-04-20'),
new DateTimeImmutable('2026-04-21'),
];
$duplicates = $this->duplicator->dupliquer($source, $targetClasses, $dueDates, $now);
self::assertCount(2, $duplicates);
self::assertTrue($duplicates[0]->classId->equals($targetClasses[0]));
self::assertTrue($duplicates[1]->classId->equals($targetClasses[1]));
self::assertSame('2026-04-20', $duplicates[0]->dueDate->format('Y-m-d'));
self::assertSame('2026-04-21', $duplicates[1]->dueDate->format('Y-m-d'));
}
#[Test]
public function itCreatesIndependentCopiesWithUniqueIds(): void
{
$source = $this->createSourceHomework();
$now = new DateTimeImmutable('2026-03-14 10:00:00');
$targetClasses = [
ClassId::fromString(self::TARGET_CLASS_1),
ClassId::fromString(self::TARGET_CLASS_2),
];
$dueDates = [
new DateTimeImmutable('2026-04-20'),
new DateTimeImmutable('2026-04-20'),
];
$duplicates = $this->duplicator->dupliquer($source, $targetClasses, $dueDates, $now);
self::assertFalse($duplicates[0]->id->equals($source->id));
self::assertFalse($duplicates[1]->id->equals($source->id));
self::assertFalse($duplicates[0]->id->equals($duplicates[1]->id));
}
#[Test]
public function itRecordsDevoirCreeEventForEachDuplicate(): void
{
$source = $this->createSourceHomework();
$now = new DateTimeImmutable('2026-03-14 10:00:00');
$targetClasses = [
ClassId::fromString(self::TARGET_CLASS_1),
ClassId::fromString(self::TARGET_CLASS_2),
];
$dueDates = [
new DateTimeImmutable('2026-04-20'),
new DateTimeImmutable('2026-04-20'),
];
$duplicates = $this->duplicator->dupliquer($source, $targetClasses, $dueDates, $now);
foreach ($duplicates as $duplicate) {
$events = $duplicate->pullDomainEvents();
self::assertCount(1, $events);
}
}
#[Test]
public function itUsesSourceDueDateWhenNoneProvided(): void
{
$source = $this->createSourceHomework();
$now = new DateTimeImmutable('2026-03-14 10:00:00');
$targetClassId = ClassId::fromString(self::TARGET_CLASS_1);
$duplicates = $this->duplicator->dupliquer($source, [$targetClassId], [], $now);
self::assertSame($source->dueDate->format('Y-m-d'), $duplicates[0]->dueDate->format('Y-m-d'));
}
#[Test]
public function itPreservesNullDescription(): void
{
$source = $this->createSourceHomework(description: null);
$now = new DateTimeImmutable('2026-03-14 10:00:00');
$targetClassId = ClassId::fromString(self::TARGET_CLASS_1);
$duplicates = $this->duplicator->dupliquer($source, [$targetClassId], [], $now);
self::assertNull($duplicates[0]->description);
}
private function createSourceHomework(?string $description = 'Faire les exercices 1 à 10'): 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: $description,
dueDate: new DateTimeImmutable('2026-04-15'),
now: new DateTimeImmutable('2026-03-12 10:00:00'),
);
}
}

View File

@@ -401,6 +401,202 @@ test.describe('Homework Management (Story 5.1)', () => {
}); });
}); });
// ============================================================================
// AC: Duplicate Homework (Story 5.2)
// ============================================================================
test.describe('Story 5.2: Duplicate Homework', () => {
test.beforeAll(async () => {
// Ensure a second class exists for duplication
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-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
async function createHomework(page: import('@playwright/test').Page, title: string) {
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(title);
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(title)).toBeVisible({ timeout: 10000 });
}
test('duplicate button is visible on homework card', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir dupliquer test');
await expect(page.getByRole('button', { name: /dupliquer/i })).toBeVisible();
});
test('opens duplicate modal with class selection', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir à dupliquer');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(dialog.getByText(/dupliquer le devoir/i)).toBeVisible();
await expect(dialog.getByText('Devoir à dupliquer')).toBeVisible();
});
test('shows target classes checkboxes excluding source class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir classes cibles');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Should show checkboxes for target classes
const checkboxes = dialog.locator('input[type="checkbox"]');
await expect(checkboxes.first()).toBeVisible({ timeout: 5000 });
});
test('can duplicate homework to a target class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir duplication réussie');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select first target class
const firstCheckbox = dialog.locator('input[type="checkbox"]').first();
await firstCheckbox.check();
// Click duplicate button
await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Homework should now appear twice (original + duplicate)
await expect(page.getByText('Devoir duplication réussie')).toHaveCount(2, { timeout: 10000 });
});
test('duplicate button is disabled when no class selected', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir sans sélection');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Duplicate button should be disabled
const duplicateBtn = dialog.getByRole('button', { name: /dupliquer/i }).last();
await expect(duplicateBtn).toBeDisabled();
});
test('cancel closes the duplicate modal', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir annulation dupli');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('can duplicate homework to multiple classes', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir multi-dupli');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select ALL available checkboxes (at least 2 target classes)
const checkboxes = dialog.locator('input[type="checkbox"]');
const count = await checkboxes.count();
expect(count).toBeGreaterThanOrEqual(1);
for (let i = 0; i < count; i++) {
await checkboxes.nth(i).check();
}
// Verify button text reflects the count
const duplicateButton = dialog.getByRole('button', {
name: new RegExp(`dupliquer \\(${count} classes?\\)`, 'i')
});
await expect(duplicateButton).toBeVisible();
await expect(duplicateButton).toBeEnabled();
// Click duplicate
await duplicateButton.click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify the homework title appears count+1 times (original + duplicates)
await expect(page.getByText('Devoir multi-dupli')).toHaveCount(count + 1, { timeout: 10000 });
});
test('can customize due date per target class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir date custom');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select the first checkbox
const firstCheckbox = dialog.locator('input[type="checkbox"]').first();
await firstCheckbox.check();
// A date input should appear after checking the checkbox
const dueDateInput = dialog.locator('input[type="date"]').first();
await expect(dueDateInput).toBeVisible({ timeout: 5000 });
// Change the due date to a custom value
await dueDateInput.fill(getNextWeekday(10));
// Click duplicate
await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify the homework appears twice (original + 1 duplicate)
await expect(page.getByText('Devoir date custom')).toHaveCount(2, { timeout: 10000 });
});
test('can filter homework list by class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir filtre classe');
// Class filter should be visible
const classFilter = page.locator('select[aria-label="Filtrer par classe"]');
await expect(classFilter).toBeVisible();
// Select a class to filter
await classFilter.selectOption({ index: 1 });
// Should still show homework (it matches the filter)
await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 });
// Select "Toutes les classes" to reset
await classFilter.selectOption({ index: 0 });
await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 });
});
});
// ============================================================================ // ============================================================================
// Partial Update // Partial Update
// ============================================================================ // ============================================================================

View File

@@ -77,6 +77,19 @@
let homeworkToDelete = $state<Homework | null>(null); let homeworkToDelete = $state<Homework | null>(null);
let isDeleting = $state(false); let isDeleting = $state(false);
// Duplicate modal
let showDuplicateModal = $state(false);
let homeworkToDuplicate = $state<Homework | null>(null);
let selectedTargetClassIds = $state<string[]>([]);
let dueDatesByClass = $state<Record<string, string>>({});
let isDuplicating = $state(false);
let duplicateError = $state<string | null>(null);
let duplicateValidationResults = $state<Array<{ classId: string; valid: boolean; error: string | null }>>([]);
let duplicateWarnings = $state<Array<{ classId: string; warning: string }>>([]);
// Class filter
let filterClassId = $state(page.url.searchParams.get('classId') ?? '');
// Derived: available subjects for selected class // Derived: available subjects for selected class
let availableSubjectsForCreate = $derived.by(() => { let availableSubjectsForCreate = $derived.by(() => {
if (!newClassId) return []; if (!newClassId) return [];
@@ -162,6 +175,7 @@
params.set('page', String(currentPage)); params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage)); params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm); if (searchTerm) params.set('search', searchTerm);
if (filterClassId) params.set('classId', filterClassId);
const response = await authenticatedFetch(`${apiUrl}/homework?${params.toString()}`, { const response = await authenticatedFetch(`${apiUrl}/homework?${params.toString()}`, {
signal: controller.signal, signal: controller.signal,
@@ -186,6 +200,7 @@
const params = new URLSearchParams(); const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage)); if (currentPage > 1) params.set('page', String(currentPage));
if (searchTerm) params.set('search', searchTerm); if (searchTerm) params.set('search', searchTerm);
if (filterClassId) params.set('classId', filterClassId);
const query = params.toString(); const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true }); goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
} }
@@ -237,6 +252,26 @@
return classes.filter((c) => assignedClassIds.includes(c.id)); return classes.filter((c) => assignedClassIds.includes(c.id));
}); });
// Available target classes for duplication (same subject, exclude source class)
let availableTargetClasses = $derived.by(() => {
if (!homeworkToDuplicate) return [];
const sourceSubjectId = homeworkToDuplicate.subjectId;
const sourceClassId = homeworkToDuplicate.classId;
const assignedClassIds = assignments
.filter((a) => a.status === 'active' && a.subjectId === sourceSubjectId)
.map((a) => a.classId);
return classes.filter((c) => assignedClassIds.includes(c.id) && c.id !== sourceClassId);
});
function handleClassFilter(newClassId: string) {
filterClassId = newClassId;
currentPage = 1;
updateUrl();
loadHomeworks().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur inconnue';
});
}
// --- Create --- // --- Create ---
function openCreateModal() { function openCreateModal() {
showCreateModal = true; showCreateModal = true;
@@ -339,6 +374,86 @@
} }
} }
// --- Duplicate ---
function openDuplicateModal(hw: Homework) {
homeworkToDuplicate = hw;
selectedTargetClassIds = [];
dueDatesByClass = {};
duplicateError = null;
duplicateValidationResults = [];
duplicateWarnings = [];
showDuplicateModal = true;
}
function closeDuplicateModal() {
showDuplicateModal = false;
homeworkToDuplicate = null;
}
function toggleTargetClass(classId: string) {
if (selectedTargetClassIds.includes(classId)) {
selectedTargetClassIds = selectedTargetClassIds.filter((id) => id !== classId);
const { [classId]: _, ...rest } = dueDatesByClass;
dueDatesByClass = rest;
} else {
selectedTargetClassIds = [...selectedTargetClassIds, classId];
}
}
async function handleDuplicate() {
if (!homeworkToDuplicate || selectedTargetClassIds.length === 0) return;
try {
isDuplicating = true;
duplicateError = null;
duplicateValidationResults = [];
const apiUrl = getApiBaseUrl();
const dueDates: Record<string, string> = {};
for (const classId of selectedTargetClassIds) {
if (dueDatesByClass[classId]) {
dueDates[classId] = dueDatesByClass[classId];
}
}
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkToDuplicate.id}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetClassIds: selectedTargetClassIds,
dueDates: Object.keys(dueDates).length > 0 ? dueDates : undefined,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
if (errorData?.results) {
duplicateValidationResults = errorData.results;
duplicateError = 'Certaines classes ne passent pas la validation.';
return;
}
throw new Error(
errorData?.['hydra:description'] ??
errorData?.error ??
errorData?.message ??
`Erreur lors de la duplication (${response.status})`,
);
}
const responseData = await response.json();
if (responseData?.warnings?.length > 0) {
duplicateWarnings = responseData.warnings;
}
closeDuplicateModal();
await loadHomeworks();
} catch (e) {
duplicateError = e instanceof Error ? e.message : 'Erreur lors de la duplication';
} finally {
isDuplicating = false;
}
}
// --- Delete --- // --- Delete ---
function openDeleteModal(hw: Homework) { function openDeleteModal(hw: Homework) {
homeworkToDelete = hw; homeworkToDelete = hw;
@@ -405,7 +520,36 @@
</div> </div>
{/if} {/if}
<SearchInput value={searchTerm} onSearch={handleSearch} placeholder="Rechercher par titre..." /> {#if duplicateWarnings.length > 0}
<div class="alert alert-warning">
<span class="alert-icon">&#9888;</span>
<div>
<strong>Duplication effectuée avec avertissements :</strong>
<ul class="warning-list">
{#each duplicateWarnings as w}
<li>{getClassName(w.classId)} : {w.warning}</li>
{/each}
</ul>
</div>
<button class="alert-close" onclick={() => (duplicateWarnings = [])}>&times;</button>
</div>
{/if}
<div class="filters-row">
<SearchInput value={searchTerm} onSearch={handleSearch} placeholder="Rechercher par titre..." />
<div class="class-filter">
<select
value={filterClassId}
aria-label="Filtrer par classe"
onchange={(e) => handleClassFilter((e.target as HTMLSelectElement).value)}
>
<option value="">Toutes les classes</option>
{#each availableClasses as cls (cls.id)}
<option value={cls.id}>{cls.name}</option>
{/each}
</select>
</div>
</div>
{#if isLoading} {#if isLoading}
<div class="loading-state" aria-live="polite" role="status"> <div class="loading-state" aria-live="polite" role="status">
@@ -462,6 +606,9 @@
<button class="btn-secondary btn-sm" onclick={() => openEditModal(hw)}> <button class="btn-secondary btn-sm" onclick={() => openEditModal(hw)}>
Modifier Modifier
</button> </button>
<button class="btn-secondary btn-sm" onclick={() => openDuplicateModal(hw)}>
Dupliquer
</button>
<button class="btn-danger btn-sm" onclick={() => openDeleteModal(hw)}> <button class="btn-danger btn-sm" onclick={() => openDeleteModal(hw)}>
Supprimer Supprimer
</button> </button>
@@ -699,6 +846,103 @@
</div> </div>
{/if} {/if}
<!-- Duplicate Modal -->
{#if showDuplicateModal && homeworkToDuplicate}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDuplicateModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="duplicate-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDuplicateModal(); }}
>
<header class="modal-header">
<h2 id="duplicate-modal-title">Dupliquer le devoir</h2>
<button class="modal-close" onclick={closeDuplicateModal} aria-label="Fermer">&times;</button>
</header>
<div class="modal-body">
<div class="form-info">
<span class="info-label">Devoir :</span>
<span>{homeworkToDuplicate.title}</span>
<span class="info-label">Classe source :</span>
<span>{homeworkToDuplicate.className ?? getClassName(homeworkToDuplicate.classId)}</span>
<span class="info-label">Matière :</span>
<span>{homeworkToDuplicate.subjectName ?? getSubjectName(homeworkToDuplicate.subjectId)}</span>
</div>
{#if duplicateError}
<div class="alert alert-error" style="margin-bottom: 1rem;">
<span class="alert-icon">&#9888;</span>
{duplicateError}
</div>
{/if}
{#if availableTargetClasses.length === 0}
<p class="empty-target-classes">Aucune autre classe disponible pour cette matière.</p>
{:else}
<div class="form-group">
<label>Classes cibles *</label>
<div class="checkbox-list">
{#each availableTargetClasses as cls (cls.id)}
{@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)}
<div class="checkbox-item" class:validation-error={validationResult && !validationResult.valid}>
<label class="checkbox-label">
<input
type="checkbox"
checked={selectedTargetClassIds.includes(cls.id)}
onchange={() => toggleTargetClass(cls.id)}
/>
{cls.name}
</label>
{#if selectedTargetClassIds.includes(cls.id)}
<div class="due-date-inline">
<label for="due-{cls.id}">Date :</label>
<input
type="date"
id="due-{cls.id}"
value={dueDatesByClass[cls.id] ?? homeworkToDuplicate.dueDate}
min={minDueDate}
onchange={(e) => {
dueDatesByClass = { ...dueDatesByClass, [cls.id]: (e.target as HTMLInputElement).value };
}}
/>
</div>
{/if}
{#if validationResult && !validationResult.valid}
<small class="form-hint form-hint-warning">{validationResult.error}</small>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeDuplicateModal} disabled={isDuplicating}>
Annuler
</button>
<button
type="button"
class="btn-primary"
onclick={handleDuplicate}
disabled={isDuplicating || selectedTargetClassIds.length === 0}
>
{#if isDuplicating}
Duplication...
{:else}
Dupliquer ({selectedTargetClassIds.length} classe{selectedTargetClassIds.length > 1 ? 's' : ''})
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}
<style> <style>
.homework-page { .homework-page {
padding: 1.5rem; padding: 1.5rem;
@@ -826,6 +1070,18 @@
opacity: 1; opacity: 1;
} }
.alert-warning {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.warning-list {
margin: 0.25rem 0 0;
padding-left: 1.25rem;
font-size: 0.875rem;
}
/* Loading & Empty states */ /* Loading & Empty states */
.loading-state, .loading-state,
.empty-state { .empty-state {
@@ -1125,4 +1381,91 @@
font-size: 0.875rem; font-size: 0.875rem;
color: #6b7280; color: #6b7280;
} }
/* Filters row */
.filters-row {
display: flex;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters-row > :first-child {
flex: 1;
min-width: 200px;
}
.class-filter select {
padding: 0.625rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
background: white;
cursor: pointer;
}
.class-filter select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Duplicate modal */
.checkbox-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.checkbox-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
}
.checkbox-item.validation-error {
border-color: #fecaca;
background: #fef2f2;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 500;
}
.checkbox-label input[type='checkbox'] {
width: 1rem;
height: 1rem;
}
.due-date-inline {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding-left: 1.5rem;
}
.due-date-inline label {
font-size: 0.875rem;
color: #6b7280;
white-space: nowrap;
}
.due-date-inline input[type='date'] {
padding: 0.375rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.empty-target-classes {
text-align: center;
color: #6b7280;
padding: 2rem 0;
}
</style> </style>