Files
Classeo/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php
Mathias STRASSER 68179a929f
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
feat: Permettre aux enseignants de dupliquer un devoir vers plusieurs classes
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.
2026-03-15 14:20:48 +01:00

553 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Functional\Scolarite\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\Homework\HomeworkStatus;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
/**
* Tests for homework API endpoints.
*
* @see Story 5.1 - Création de Devoirs
*/
final class HomeworkEndpointsTest extends ApiTestCase
{
/**
* Opt-in for API Platform 5.0 behavior where kernel boot is explicit.
*
* @see https://github.com/api-platform/core/issues/6971
*/
protected static ?bool $alwaysBootKernel = true;
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string OWNER_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440000';
private const string OTHER_TEACHER_ID = '660e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-776655440001';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-886655440001';
private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-996655440001';
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
{
parent::setUp();
$this->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 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 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],
);
$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],
);
}
// =========================================================================
// 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);
}
// =========================================================================
// 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
// =========================================================================
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);
}
}