feat: Afficher les statistiques de notes par matière côté administration

L'admin doit pouvoir voir en un coup d'œil quelles matières sont
actives (notes saisies) pour décider lesquelles peuvent être supprimées
sans perte de données. Auparavant, la suppression d'une matière était
silencieuse : elle cascade-deletait évaluations et notes sans avertir.

La liste des matières affiche désormais les compteurs d'enseignants,
classes, évaluations et notes. La suppression déclenche une confirmation
explicite quand la matière contient des notes, avec récapitulatif des
volumes impactés, pour rendre l'action irréversible consciente.

Côté tests, un endpoint de seeding HTTP remplace les appels docker exec
dans les E2E (gain ~30-60s → 5-10s par test), et un trait partagé
factorise le SQL de seeding entre les deux suites fonctionnelles.
This commit is contained in:
2026-04-21 15:37:25 +02:00
parent dc2be898d5
commit 86d00ce733
21 changed files with 1602 additions and 42 deletions

View File

@@ -451,6 +451,10 @@ services:
App\Administration\Application\Port\GradeExistenceChecker:
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
# SubjectGradeStatsReader (implémentation Scolarite via SQL)
App\Administration\Application\Port\SubjectGradeStatsReader:
alias: App\Scolarite\Infrastructure\Service\DoctrineSubjectGradeStatsReader
# ActiveRoleStore (session-scoped cache for active role switching)
App\Administration\Application\Port\ActiveRoleStore:
alias: App\Administration\Infrastructure\Service\CacheActiveRoleStore

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
/**
* Statistiques d'impact d'une suppression de matière : évaluations et notes liées.
*/
final readonly class SubjectGradeStats
{
public function __construct(
public int $evaluationCount,
public int $gradeCount,
) {
}
public function hasGrades(): bool
{
return $this->gradeCount > 0 || $this->evaluationCount > 0;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Shared\Domain\Tenant\TenantId;
/**
* Port pour récupérer les statistiques d'impact d'une suppression de matière.
*
* Implémenté par le module Notes/Évaluations (Scolarite) via SQL.
*/
interface SubjectGradeStatsReader
{
public function countForSubject(
TenantId $tenantId,
SubjectId $subjectId,
): SubjectGradeStats;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetSubjectGradeStats;
use App\Administration\Application\Port\SubjectGradeStats;
use App\Administration\Application\Port\SubjectGradeStatsReader;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetSubjectGradeStatsHandler
{
public function __construct(
private SubjectGradeStatsReader $reader,
) {
}
public function __invoke(GetSubjectGradeStatsQuery $query): SubjectGradeStats
{
return $this->reader->countForSubject(
TenantId::fromString($query->tenantId),
SubjectId::fromString($query->subjectId),
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetSubjectGradeStats;
/**
* Query pour obtenir le nombre d'évaluations et de notes liées à une matière.
*
* Utilisée pour avertir l'administrateur avant suppression d'une matière.
*/
final readonly class GetSubjectGradeStatsQuery
{
public function __construct(
public string $tenantId,
public string $subjectId,
) {
}
}

View File

@@ -23,6 +23,8 @@ final readonly class SubjectDto
public DateTimeImmutable $updatedAt,
public int $teacherCount = 0,
public int $classCount = 0,
public int $evaluationCount = 0,
public int $gradeCount = 0,
) {
}
@@ -30,6 +32,8 @@ final readonly class SubjectDto
Subject $subject,
int $teacherCount = 0,
int $classCount = 0,
int $evaluationCount = 0,
int $gradeCount = 0,
): self {
return new self(
id: (string) $subject->id,
@@ -42,6 +46,13 @@ final readonly class SubjectDto
updatedAt: $subject->updatedAt,
teacherCount: $teacherCount,
classCount: $classCount,
evaluationCount: $evaluationCount,
gradeCount: $gradeCount,
);
}
public function hasGrades(): bool
{
return $this->evaluationCount > 0 || $this->gradeCount > 0;
}
}

View File

@@ -8,13 +8,20 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectCommand;
use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectHandler;
use App\Administration\Application\Query\GetSubjectGradeStats\GetSubjectGradeStatsHandler;
use App\Administration\Application\Query\GetSubjectGradeStats\GetSubjectGradeStatsQuery;
use App\Administration\Domain\Exception\SubjectNotFoundException;
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
use App\Administration\Infrastructure\Security\SubjectVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use function sprintf;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -23,9 +30,15 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Processor API Platform pour supprimer (archiver) une matière.
*
* Note: Cette implémentation fait un soft delete (archivage).
* La vérification des notes associées (T6) sera ajoutée ultérieurement
* quand le module Notes sera implémenté.
* Soft delete (archivage). Si des évaluations ou notes sont liées, une confirmation
* explicite est requise via le paramètre `?confirm=true` afin que l'admin ait
* conscience de l'impact.
*
* TOCTOU : la lecture des stats et l'archivage ne sont pas atomiques. Une évaluation
* peut être créée entre la vérification et l'archive — acceptable car :
* 1. L'archive est réversible (flag `deleted_at`, pas de DROP de données)
* 2. Les évaluations/notes créées pendant la fenêtre restent consultables via l'historique
* 3. L'impact affiché à l'admin est au pire sous-estimé, jamais sur-estimé
*
* @implements ProcessorInterface<SubjectResource, null>
*/
@@ -33,9 +46,11 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface
{
public function __construct(
private ArchiveSubjectHandler $handler,
private GetSubjectGradeStatsHandler $gradeStatsHandler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private RequestStack $requestStack,
) {
}
@@ -62,8 +77,20 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
// TODO: Vérifier si des notes sont associées (T6)
// et retourner un warning si c'est le cas (via query param ?confirm=true)
if (!$this->isConfirmed()) {
$stats = ($this->gradeStatsHandler)(new GetSubjectGradeStatsQuery(
tenantId: $tenantId,
subjectId: $subjectId,
));
if ($stats->hasGrades()) {
throw new ConflictHttpException(sprintf(
'Cette matière est liée à %d évaluation(s) et %d note(s). Confirmez la suppression pour continuer.',
$stats->evaluationCount,
$stats->gradeCount,
));
}
}
$command = new ArchiveSubjectCommand(
subjectId: $subjectId,
@@ -72,7 +99,7 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface
$subject = ($this->handler)($command);
// Dispatch domain events from the archived aggregate
// Propage les événements domaine (MatiereSupprimee, etc.) émis par l'agrégat.
foreach ($subject->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
@@ -82,4 +109,15 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface
throw new NotFoundHttpException('Matière non trouvée.');
}
}
private function isConfirmed(): bool
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
return false;
}
return $request->query->getBoolean('confirm');
}
}

View File

@@ -110,6 +110,18 @@ final class SubjectResource
#[ApiProperty(readable: true, writable: false)]
public ?int $classCount = null;
/**
* Statistiques : nombre d'évaluations créées pour cette matière.
*/
#[ApiProperty(readable: true, writable: false)]
public ?int $evaluationCount = null;
/**
* Statistiques : nombre de notes saisies pour cette matière.
*/
#[ApiProperty(readable: true, writable: false)]
public ?int $gradeCount = null;
/**
* Permet de supprimer explicitement la couleur lors d'un PATCH.
* Si true, la couleur sera mise à null même si color n'est pas fourni.
@@ -164,6 +176,9 @@ final class SubjectResource
$resource->updatedAt = $dto->updatedAt;
$resource->teacherCount = $dto->teacherCount;
$resource->classCount = $dto->classCount;
$resource->evaluationCount = $dto->evaluationCount;
$resource->gradeCount = $dto->gradeCount;
$resource->hasGrades = $dto->hasGrades();
return $resource;
}

View File

@@ -52,7 +52,9 @@ final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsRea
s.id, s.name, s.code, s.color, s.description, s.status,
s.created_at, s.updated_at,
(SELECT COUNT(*) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS teacher_count,
(SELECT COUNT(DISTINCT ta.school_class_id) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS class_count
(SELECT COUNT(DISTINCT ta.school_class_id) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS class_count,
(SELECT COUNT(*) FROM evaluations e WHERE e.subject_id = s.id AND e.tenant_id = s.tenant_id) AS evaluation_count,
(SELECT COUNT(g.id) FROM grades g INNER JOIN evaluations e ON e.id = g.evaluation_id WHERE e.subject_id = s.id AND e.tenant_id = s.tenant_id AND g.tenant_id = s.tenant_id) AS grade_count
FROM subjects s
WHERE {$whereClause}
ORDER BY s.name ASC
@@ -85,6 +87,10 @@ final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsRea
$teacherCountRaw = $row['teacher_count'] ?? 0;
/** @var int|string $classCountRaw */
$classCountRaw = $row['class_count'] ?? 0;
/** @var int|string $evaluationCountRaw */
$evaluationCountRaw = $row['evaluation_count'] ?? 0;
/** @var int|string $gradeCountRaw */
$gradeCountRaw = $row['grade_count'] ?? 0;
return new SubjectDto(
id: $id,
@@ -97,6 +103,8 @@ final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsRea
updatedAt: new DateTimeImmutable($updatedAt),
teacherCount: (int) $teacherCountRaw,
classCount: (int) $classCountRaw,
evaluationCount: (int) $evaluationCountRaw,
gradeCount: (int) $gradeCountRaw,
);
}, $rows);

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Controller;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Doctrine\DBAL\Connection;
use function is_array;
use function is_int;
use function is_string;
use function json_decode;
use const JSON_THROW_ON_ERROR;
use JsonException;
use Ramsey\Uuid\Uuid;
use function sprintf;
use Symfony\Component\DependencyInjection\Attribute\When;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\Attribute\Route;
/**
* Endpoint de seeding E2E : crée en un appel HTTP la classe, les évaluations
* et les notes attachées à une matière donnée, et offre un endpoint de cleanup
* symétrique pour purger ces données après le test.
*
* Remplace la séquence fragile `docker compose exec php bin/console dbal:run-sql`
* utilisée précédemment dans `frontend/e2e/subjects.spec.ts` (AC3) et réduit
* le coût d'un test de ~30-60 s à ~5-10 s en supprimant 6+ cold starts du kernel.
*
* Non enregistré en production : `#[When('!prod')]`.
*
* @see Story 6.10 — Statistiques notes par matière (admin)
*/
#[When('!prod')]
final readonly class TestSeedSubjectWithGradesController
{
public function __construct(
private Connection $connection,
private TenantContext $tenantContext,
) {
}
#[Route('/test/seed/subject-with-grades', name: 'test_seed_subject_with_grades', methods: ['POST'])]
public function seed(Request $request): JsonResponse
{
$tenantId = $this->requireTenantId();
$payload = $this->decodeJson($request->getContent());
$subjectId = $this->requireString($payload, 'subjectId');
$teacherEmail = $this->requireString($payload, 'teacherEmail');
$evaluationCount = $this->optionalInt($payload, 'evaluationCount', 2);
$gradesPerEval = $this->optionalInt($payload, 'gradesPerEval', 1);
if ($evaluationCount < 1 || $gradesPerEval < 0) {
throw new BadRequestHttpException('evaluationCount must be >= 1 and gradesPerEval >= 0');
}
$teacherId = $this->resolveTeacherId($tenantId, $teacherEmail);
$classId = $this->findOrCreateClass($tenantId);
$evaluationIds = [];
for ($i = 1; $i <= $evaluationCount; ++$i) {
$evaluationIds[] = $this->insertEvaluation(
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
title: sprintf('Eval %d', $i),
);
}
$gradeIds = [];
foreach ($evaluationIds as $evaluationId) {
for ($j = 1; $j <= $gradesPerEval; ++$j) {
$gradeIds[] = $this->insertGrade(
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $teacherId,
value: 10.0 + $j,
);
}
}
return new JsonResponse([
'classId' => $classId,
'evaluationIds' => $evaluationIds,
'gradeIds' => $gradeIds,
]);
}
#[Route(
'/test/seed/subject-with-grades/{subjectId}',
name: 'test_seed_subject_with_grades_cleanup',
requirements: ['subjectId' => '[0-9a-f-]{36}'],
methods: ['DELETE'],
)]
public function cleanup(string $subjectId): JsonResponse
{
$tenantId = $this->requireTenantId();
$this->connection->executeStatement(
'DELETE FROM grades
WHERE tenant_id = :tenant
AND evaluation_id IN (
SELECT id FROM evaluations WHERE tenant_id = :tenant AND subject_id = :subject
)',
['tenant' => $tenantId, 'subject' => $subjectId],
);
$this->connection->executeStatement(
'DELETE FROM evaluations WHERE tenant_id = :tenant AND subject_id = :subject',
['tenant' => $tenantId, 'subject' => $subjectId],
);
return new JsonResponse(null, 204);
}
private function requireTenantId(): string
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
return (string) $this->tenantContext->getCurrentTenantId();
}
/**
* @return array<string, mixed>
*/
private function decodeJson(string $raw): array
{
if ($raw === '') {
return [];
}
try {
$decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new BadRequestHttpException('Invalid JSON payload: ' . $e->getMessage(), $e);
}
if (!is_array($decoded)) {
throw new BadRequestHttpException('Payload must be a JSON object');
}
$result = [];
foreach ($decoded as $key => $value) {
if (!is_string($key)) {
throw new BadRequestHttpException('Payload must be a JSON object (got a list)');
}
$result[$key] = $value;
}
return $result;
}
/**
* @param array<string, mixed> $payload
*/
private function requireString(array $payload, string $key): string
{
$value = $payload[$key] ?? null;
if (!is_string($value) || $value === '') {
throw new BadRequestHttpException(sprintf('%s required (non-empty string)', $key));
}
return $value;
}
/**
* @param array<string, mixed> $payload
*/
private function optionalInt(array $payload, string $key, int $default): int
{
$value = $payload[$key] ?? $default;
if (!is_int($value)) {
throw new BadRequestHttpException(sprintf('%s must be an integer', $key));
}
return $value;
}
private function resolveTeacherId(string $tenantId, string $email): string
{
/** @var string|false $teacherId */
$teacherId = $this->connection->fetchOne(
'SELECT id FROM users WHERE tenant_id = :tenant AND email = :email LIMIT 1',
['tenant' => $tenantId, 'email' => $email],
);
if ($teacherId === false) {
throw new BadRequestHttpException(sprintf(
'No user found for email "%s" in current tenant.',
$email,
));
}
return $teacherId;
}
private function findOrCreateClass(string $tenantId): string
{
/** @var string|false $existing */
$existing = $this->connection->fetchOne(
'SELECT id FROM school_classes WHERE tenant_id = :tenant LIMIT 1',
['tenant' => $tenantId],
);
if ($existing !== false) {
return $existing;
}
$classId = Uuid::uuid4()->toString();
$this->connection->executeStatement(
"INSERT INTO school_classes
(id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at)
VALUES (:id, :tenant, :tenant, :tenant, 'Classe E2E', '6e', 'active', NOW(), NOW())",
['id' => $classId, 'tenant' => $tenantId],
);
return $classId;
}
private function insertEvaluation(
string $tenantId,
string $classId,
string $subjectId,
string $teacherId,
string $title,
): string {
$id = Uuid::uuid4()->toString();
$this->connection->executeStatement(
'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date)
VALUES (:id, :tenant, :class, :subject, :teacher, :title, CURRENT_DATE)',
[
'id' => $id,
'tenant' => $tenantId,
'class' => $classId,
'subject' => $subjectId,
'teacher' => $teacherId,
'title' => $title,
],
);
return $id;
}
private function insertGrade(
string $tenantId,
string $evaluationId,
string $studentId,
float $value,
): string {
$id = Uuid::uuid4()->toString();
$this->connection->executeStatement(
'INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, created_by)
VALUES (:id, :tenant, :eval, :student, :value, :student)',
[
'id' => $id,
'tenant' => $tenantId,
'eval' => $evaluationId,
'student' => $studentId,
'value' => $value,
],
);
return $id;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Service;
use App\Administration\Application\Port\SubjectGradeStats;
use App\Administration\Application\Port\SubjectGradeStatsReader;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Shared\Domain\Tenant\TenantId;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineSubjectGradeStatsReader implements SubjectGradeStatsReader
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function countForSubject(
TenantId $tenantId,
SubjectId $subjectId,
): SubjectGradeStats {
/** @var int|string|false $evaluationCountRaw */
$evaluationCountRaw = $this->connection->fetchOne(
'SELECT COUNT(*) FROM evaluations WHERE subject_id = :subject_id AND tenant_id = :tenant_id',
[
'subject_id' => (string) $subjectId,
'tenant_id' => (string) $tenantId,
],
);
/** @var int|string|false $gradeCountRaw */
$gradeCountRaw = $this->connection->fetchOne(
'SELECT COUNT(g.id)
FROM grades g
INNER JOIN evaluations e ON e.id = g.evaluation_id
WHERE e.subject_id = :subject_id
AND e.tenant_id = :tenant_id
AND g.tenant_id = :tenant_id',
[
'subject_id' => (string) $subjectId,
'tenant_id' => (string) $tenantId,
],
);
return new SubjectGradeStats(
evaluationCount: (int) $evaluationCountRaw,
gradeCount: (int) $gradeCountRaw,
);
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Administration\Infrastructure\ReadModel;
use App\Administration\Application\Port\PaginatedSubjectsReader;
use App\Tests\Functional\Helpers\SubjectStatsSeedingTrait;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class DbalPaginatedSubjectsReaderTest extends KernelTestCase
{
use SubjectStatsSeedingTrait;
// Plage UUID 0020-0029 réservée à cette suite pour éviter les collisions
// avec d'autres tests fonctionnels qui seed les mêmes tables.
private const string TENANT_A = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0020';
private const string TENANT_B = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0021';
private const string SCHOOL_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0022';
private Connection $sharedConnection;
private PaginatedSubjectsReader $reader;
protected function setUp(): void
{
self::bootKernel();
/** @var Connection $connection */
$connection = static::getContainer()->get(Connection::class);
$this->sharedConnection = $connection;
/** @var PaginatedSubjectsReader $reader */
$reader = static::getContainer()->get(PaginatedSubjectsReader::class);
$this->reader = $reader;
}
protected function tearDown(): void
{
$this->cleanupSubjectStatsData([self::TENANT_A, self::TENANT_B]);
parent::tearDown();
}
protected function connection(): Connection
{
return $this->sharedConnection;
}
#[Test]
public function paginatedResultExposesZeroStatsWhenSubjectHasNoData(): void
{
$this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Matière vide', 'EMPTY');
$result = $this->reader->findPaginated(
tenantId: self::TENANT_A,
schoolId: self::SCHOOL_ID,
search: 'Matière vide',
page: 1,
limit: 30,
);
self::assertCount(1, $result->items);
$dto = $result->items[0];
self::assertSame('Matière vide', $dto->name);
self::assertSame(0, $dto->teacherCount);
self::assertSame(0, $dto->classCount);
self::assertSame(0, $dto->evaluationCount);
self::assertSame(0, $dto->gradeCount);
self::assertFalse($dto->hasGrades());
}
#[Test]
public function paginatedResultCountsEvaluationsGradesTeachersAndClassesPerSubject(): void
{
$subjectId = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Mathématiques', 'MATH2');
$classId = $this->insertClass(self::TENANT_A, self::SCHOOL_ID);
$teacherId = $this->insertUser(self::TENANT_A, 'paginated-teacher@test.local');
$studentId = $this->insertUser(self::TENANT_A, 'paginated-student@test.local');
$this->insertTeacherAssignment(self::TENANT_A, $teacherId, $classId, $subjectId, self::SCHOOL_ID);
$eval1 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Eval 1');
$eval2 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Eval 2');
$this->insertGrade(self::TENANT_A, $eval1, $studentId, 15.0);
$this->insertGrade(self::TENANT_A, $eval2, $studentId, 18.0);
$result = $this->reader->findPaginated(
tenantId: self::TENANT_A,
schoolId: self::SCHOOL_ID,
search: 'Mathématiques',
page: 1,
limit: 30,
);
self::assertCount(1, $result->items);
$dto = $result->items[0];
self::assertSame(1, $dto->teacherCount);
self::assertSame(1, $dto->classCount);
self::assertSame(2, $dto->evaluationCount);
self::assertSame(2, $dto->gradeCount);
self::assertTrue($dto->hasGrades());
}
#[Test]
public function paginatedResultDoesNotLeakStatsFromOtherTenants(): void
{
$subjectA = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Isolation', 'ISO');
// Données tenant B avec le même nom de matière
$subjectB = $this->insertSubject(self::TENANT_B, self::SCHOOL_ID, 'Isolation', 'ISO');
$classB = $this->insertClass(self::TENANT_B, self::SCHOOL_ID);
$teacherB = $this->insertUser(self::TENANT_B, 'isolation-teacher@test.local');
$studentB = $this->insertUser(self::TENANT_B, 'isolation-student@test.local');
$this->insertTeacherAssignment(self::TENANT_B, $teacherB, $classB, $subjectB, self::SCHOOL_ID);
$evalB = $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B');
$this->insertGrade(self::TENANT_B, $evalB, $studentB, 10.0);
$resultA = $this->reader->findPaginated(
tenantId: self::TENANT_A,
schoolId: self::SCHOOL_ID,
search: 'Isolation',
page: 1,
limit: 30,
);
self::assertCount(1, $resultA->items);
$dto = $resultA->items[0];
self::assertSame($subjectA, $dto->id);
self::assertSame(0, $dto->teacherCount);
self::assertSame(0, $dto->classCount);
self::assertSame(0, $dto->evaluationCount);
self::assertSame(0, $dto->gradeCount);
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Helpers;
use Doctrine\DBAL\Connection;
use Ramsey\Uuid\Uuid;
use function sprintf;
/**
* Mutualise les helpers SQL de seeding pour les tests fonctionnels qui couvrent
* les statistiques de matière (compteurs d'évaluations, de notes, d'enseignants
* et de classes). Deux suites partagent exactement ces inserts :
*
* - `DoctrineSubjectGradeStatsReaderTest` (périmètre Scolarite — reader dédié)
* - `DbalPaginatedSubjectsReaderTest` (périmètre Administration — reader paginé)
*
* Chaque classe utilisatrice garde ses propres constantes d'UUID (plages
* disjointes) et expose sa `Connection` via `connection()`. Le trait n'a pas
* d'état : il ne fait que factoriser le SQL brut.
*/
trait SubjectStatsSeedingTrait
{
abstract protected function connection(): Connection;
protected function insertSubject(string $tenantId, string $schoolId, string $name, string $code): string
{
$id = Uuid::uuid4()->toString();
$this->connection()->executeStatement(
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
VALUES (:id, :tenant, :school, :name, :code, 'active', NOW(), NOW())",
[
'id' => $id,
'tenant' => $tenantId,
'school' => $schoolId,
'name' => $name,
'code' => $code,
],
);
return $id;
}
protected function insertClass(string $tenantId, string $schoolId): string
{
$id = Uuid::uuid4()->toString();
$this->connection()->executeStatement(
"INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at)
VALUES (:id, :tenant, :school, :year, '6e A', '6e', 'active', NOW(), NOW())",
[
'id' => $id,
'tenant' => $tenantId,
'school' => $schoolId,
'year' => $schoolId,
],
);
return $id;
}
protected function insertUser(string $tenantId, string $email): string
{
$id = Uuid::uuid4()->toString();
$this->connection()->executeStatement(
"INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, statut, school_name, image_rights_status, created_at, updated_at)
VALUES (:id, :tenant, :email, 'Test', 'User', '[\"ROLE_USER\"]', 'active', 'Test School', 'not_requested', NOW(), NOW())",
[
'id' => $id,
'tenant' => $tenantId,
'email' => $email,
],
);
return $id;
}
protected function insertTeacherAssignment(
string $tenantId,
string $teacherId,
string $classId,
string $subjectId,
string $academicYearId,
): void {
$this->connection()->executeStatement(
"INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, start_date, status, created_at, updated_at)
VALUES (:id, :tenant, :teacher, :class, :subject, :year, NOW(), 'active', NOW(), NOW())",
[
'id' => Uuid::uuid4()->toString(),
'tenant' => $tenantId,
'teacher' => $teacherId,
'class' => $classId,
'subject' => $subjectId,
'year' => $academicYearId,
],
);
}
protected function insertEvaluation(
string $tenantId,
string $classId,
string $subjectId,
string $teacherId,
string $title,
): string {
$id = Uuid::uuid4()->toString();
$this->connection()->executeStatement(
'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date)
VALUES (:id, :tenant, :class, :subject, :teacher, :title, CURRENT_DATE)',
[
'id' => $id,
'tenant' => $tenantId,
'class' => $classId,
'subject' => $subjectId,
'teacher' => $teacherId,
'title' => $title,
],
);
return $id;
}
protected function insertGrade(string $tenantId, string $evaluationId, string $studentId, float $value): void
{
$this->connection()->executeStatement(
'INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, created_by)
VALUES (:id, :tenant, :eval, :student, :value, :student)',
[
'id' => Uuid::uuid4()->toString(),
'tenant' => $tenantId,
'eval' => $evaluationId,
'student' => $studentId,
'value' => $value,
],
);
}
/**
* Purge les tables de seeding pour les tenants donnés.
*
* L'ordre respecte les clés étrangères (grades → evaluations → teacher_assignments → subjects → school_classes → users).
*
* @param list<string> $tenantIds
*/
protected function cleanupSubjectStatsData(array $tenantIds): void
{
if ($tenantIds === []) {
return;
}
$placeholders = implode(',', array_map(static fn (int $i) => ':tenant_' . $i, array_keys($tenantIds)));
$params = [];
foreach ($tenantIds as $i => $tenantId) {
$params['tenant_' . $i] = $tenantId;
}
foreach (
[
'grades',
'evaluations',
'teacher_assignments',
'subjects',
'school_classes',
'users',
] as $table
) {
$this->connection()->executeStatement(
sprintf('DELETE FROM %s WHERE tenant_id IN (%s)', $table, $placeholders),
$params,
);
}
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Scolarite\Infrastructure\Service;
use App\Administration\Application\Port\SubjectGradeStatsReader;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Shared\Domain\Tenant\TenantId;
use App\Tests\Functional\Helpers\SubjectStatsSeedingTrait;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class DoctrineSubjectGradeStatsReaderTest extends KernelTestCase
{
use SubjectStatsSeedingTrait;
// Plage UUID 0010-0019 réservée à cette suite pour éviter les collisions
// avec d'autres tests fonctionnels qui seed les mêmes tables.
private const string TENANT_A = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0010';
private const string TENANT_B = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0011';
private const string SCHOOL_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0012';
private Connection $sharedConnection;
private SubjectGradeStatsReader $reader;
protected function setUp(): void
{
self::bootKernel();
/** @var Connection $connection */
$connection = static::getContainer()->get(Connection::class);
$this->sharedConnection = $connection;
/** @var SubjectGradeStatsReader $reader */
$reader = static::getContainer()->get(SubjectGradeStatsReader::class);
$this->reader = $reader;
}
protected function tearDown(): void
{
$this->cleanupSubjectStatsData([self::TENANT_A, self::TENANT_B]);
parent::tearDown();
}
protected function connection(): Connection
{
return $this->sharedConnection;
}
#[Test]
public function returnsZeroStatsWhenSubjectHasNoEvaluation(): void
{
$subjectId = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Sans évaluation', 'EMPTY');
$stats = $this->reader->countForSubject(
TenantId::fromString(self::TENANT_A),
SubjectId::fromString($subjectId),
);
self::assertSame(0, $stats->evaluationCount);
self::assertSame(0, $stats->gradeCount);
self::assertFalse($stats->hasGrades());
}
#[Test]
public function countsEvaluationsAndGradesLinkedToSubject(): void
{
$subjectId = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Maths', 'MATH');
$classId = $this->insertClass(self::TENANT_A, self::SCHOOL_ID);
$teacherId = $this->insertUser(self::TENANT_A, 'teacher-math@test.local');
$studentAId = $this->insertUser(self::TENANT_A, 'student-a@test.local');
$studentBId = $this->insertUser(self::TENANT_A, 'student-b@test.local');
$eval1 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Contrôle 1');
$eval2 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Contrôle 2');
$this->insertGrade(self::TENANT_A, $eval1, $studentAId, 15.0);
$this->insertGrade(self::TENANT_A, $eval1, $studentBId, 12.5);
$this->insertGrade(self::TENANT_A, $eval2, $studentAId, 18.0);
$stats = $this->reader->countForSubject(
TenantId::fromString(self::TENANT_A),
SubjectId::fromString($subjectId),
);
self::assertSame(2, $stats->evaluationCount);
self::assertSame(3, $stats->gradeCount);
self::assertTrue($stats->hasGrades());
}
#[Test]
public function doesNotCountDataFromOtherTenants(): void
{
$subjectA = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Histoire', 'HIST');
$subjectB = $this->insertSubject(self::TENANT_B, self::SCHOOL_ID, 'Histoire', 'HIST');
// Tenant B seed : 3 évaluations + 2 notes sur son subject
$classB = $this->insertClass(self::TENANT_B, self::SCHOOL_ID);
$teacherB = $this->insertUser(self::TENANT_B, 'teacher-b@test.local');
$studentB = $this->insertUser(self::TENANT_B, 'student-b-isolation@test.local');
$evalB1 = $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B1');
$evalB2 = $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B2');
$this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B3');
$this->insertGrade(self::TENANT_B, $evalB1, $studentB, 10.0);
$this->insertGrade(self::TENANT_B, $evalB2, $studentB, 14.0);
$statsA = $this->reader->countForSubject(
TenantId::fromString(self::TENANT_A),
SubjectId::fromString($subjectA),
);
self::assertSame(0, $statsA->evaluationCount, 'Pas de fuite des évaluations du tenant B');
self::assertSame(0, $statsA->gradeCount, 'Pas de fuite des notes du tenant B');
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetSubjectGradeStats;
use App\Administration\Application\Port\SubjectGradeStats;
use App\Administration\Application\Port\SubjectGradeStatsReader;
use App\Administration\Application\Query\GetSubjectGradeStats\GetSubjectGradeStatsHandler;
use App\Administration\Application\Query\GetSubjectGradeStats\GetSubjectGradeStatsQuery;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Shared\Domain\Tenant\TenantId;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetSubjectGradeStatsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440002';
#[Test]
public function itReturnsZeroStatsWhenSubjectHasNoEvaluations(): void
{
$handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 0, grades: 0));
$stats = $handler(new GetSubjectGradeStatsQuery(
tenantId: self::TENANT_ID,
subjectId: self::SUBJECT_ID,
));
self::assertSame(0, $stats->evaluationCount);
self::assertSame(0, $stats->gradeCount);
self::assertFalse($stats->hasGrades());
}
#[Test]
public function itReturnsCountsWhenEvaluationsExist(): void
{
$handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 3, grades: 42));
$stats = $handler(new GetSubjectGradeStatsQuery(
tenantId: self::TENANT_ID,
subjectId: self::SUBJECT_ID,
));
self::assertSame(3, $stats->evaluationCount);
self::assertSame(42, $stats->gradeCount);
self::assertTrue($stats->hasGrades());
}
#[Test]
public function itConsidersSubjectWithEvaluationsButNoGradesAsHavingImpact(): void
{
$handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 2, grades: 0));
$stats = $handler(new GetSubjectGradeStatsQuery(
tenantId: self::TENANT_ID,
subjectId: self::SUBJECT_ID,
));
self::assertSame(2, $stats->evaluationCount);
self::assertSame(0, $stats->gradeCount);
self::assertTrue($stats->hasGrades(), 'Une évaluation sans notes reste un impact à signaler.');
}
#[Test]
public function itConsidersSubjectWithGradesButNoEvaluationsAsHavingImpact(): void
{
// Théoriquement impossible via la FK grades.evaluation_id → evaluations(id),
// mais on couvre la logique `||` du value object contre toute régression.
$handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 0, grades: 5));
$stats = $handler(new GetSubjectGradeStatsQuery(
tenantId: self::TENANT_ID,
subjectId: self::SUBJECT_ID,
));
self::assertSame(0, $stats->evaluationCount);
self::assertSame(5, $stats->gradeCount);
self::assertTrue($stats->hasGrades());
}
#[Test]
public function itPassesQueryParamsToReader(): void
{
$reader = new class implements SubjectGradeStatsReader {
public ?string $receivedTenantId = null;
public ?string $receivedSubjectId = null;
#[Override]
public function countForSubject(TenantId $tenantId, SubjectId $subjectId): SubjectGradeStats
{
$this->receivedTenantId = (string) $tenantId;
$this->receivedSubjectId = (string) $subjectId;
return new SubjectGradeStats(0, 0);
}
};
$handler = new GetSubjectGradeStatsHandler($reader);
$handler(new GetSubjectGradeStatsQuery(
tenantId: self::TENANT_ID,
subjectId: self::SUBJECT_ID,
));
self::assertSame(self::TENANT_ID, $reader->receivedTenantId);
self::assertSame(self::SUBJECT_ID, $reader->receivedSubjectId);
}
private function createReader(int $evaluations, int $grades): SubjectGradeStatsReader
{
return new class($evaluations, $grades) implements SubjectGradeStatsReader {
public function __construct(
private int $evaluations,
private int $grades,
) {
}
#[Override]
public function countForSubject(TenantId $tenantId, SubjectId $subjectId): SubjectGradeStats
{
return new SubjectGradeStats($this->evaluations, $this->grades);
}
};
}
}

View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Delete;
use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectHandler;
use App\Administration\Application\Port\SubjectGradeStats;
use App\Administration\Application\Port\SubjectGradeStatsReader;
use App\Administration\Application\Query\GetSubjectGradeStats\GetSubjectGradeStatsHandler;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Infrastructure\Api\Processor\DeleteSubjectProcessor;
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Administration\Infrastructure\Security\SubjectVoter;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId as DomainTenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class DeleteSubjectProcessorTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440003';
private InMemorySubjectRepository $subjectRepository;
private TenantContext $tenantContext;
private Clock $clock;
protected function setUp(): void
{
$this->subjectRepository = new InMemorySubjectRepository();
$this->clock = new class implements Clock {
#[Override]
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-04-16 10:00:00');
}
};
$this->tenantContext = new TenantContext();
$this->tenantContext->setCurrentTenant(new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_ID),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://test',
));
}
#[Test]
public function itDeletesSubjectWhenNoGradesExist(): void
{
$subject = $this->persistSubject();
$processor = $this->createProcessor(statsReader: $this->statsReader(0, 0));
$result = $processor->process(
SubjectResource::fromDomain($subject),
new Delete(),
['id' => (string) $subject->id],
);
self::assertNull($result);
$reloaded = $this->subjectRepository->get($subject->id);
self::assertNotNull($reloaded->deletedAt);
}
#[Test]
public function itThrowsConflictWhenGradesExistAndConfirmNotSet(): void
{
$subject = $this->persistSubject();
$processor = $this->createProcessor(statsReader: $this->statsReader(3, 42));
$this->expectException(ConflictHttpException::class);
$this->expectExceptionMessageMatches('/3 évaluation\(s\) et 42 note\(s\)/');
$processor->process(
SubjectResource::fromDomain($subject),
new Delete(),
['id' => (string) $subject->id],
);
}
#[Test]
public function itDeletesSubjectWhenConfirmIsTrue(): void
{
$subject = $this->persistSubject();
$processor = $this->createProcessor(
statsReader: $this->statsReader(3, 42),
request: new Request(query: ['confirm' => 'true']),
);
$result = $processor->process(
SubjectResource::fromDomain($subject),
new Delete(),
['id' => (string) $subject->id],
);
self::assertNull($result);
$reloaded = $this->subjectRepository->get($subject->id);
self::assertNotNull($reloaded->deletedAt);
}
#[Test]
public function itRejectsUnauthorizedAccess(): void
{
$subject = $this->persistSubject();
$processor = $this->createProcessor(granted: false);
$this->expectException(AccessDeniedHttpException::class);
$processor->process(
SubjectResource::fromDomain($subject),
new Delete(),
['id' => (string) $subject->id],
);
}
#[Test]
public function itRejectsWhenTenantNotSet(): void
{
$subject = $this->persistSubject();
$processor = $this->createProcessor(tenantContext: new TenantContext());
$this->expectException(UnauthorizedHttpException::class);
$processor->process(
SubjectResource::fromDomain($subject),
new Delete(),
['id' => (string) $subject->id],
);
}
#[Test]
public function itReturnsNotFoundWhenIdMissing(): void
{
$subject = $this->persistSubject();
$processor = $this->createProcessor();
$this->expectException(NotFoundHttpException::class);
$processor->process(SubjectResource::fromDomain($subject), new Delete(), []);
}
private function persistSubject(): Subject
{
$subject = Subject::creer(
tenantId: DomainTenantId::fromString(self::TENANT_ID),
schoolId: SchoolId::fromString(self::SCHOOL_ID),
name: new SubjectName('Mathématiques'),
code: new SubjectCode('MATH'),
color: null,
createdAt: $this->clock->now(),
);
$this->subjectRepository->save($subject);
return $subject;
}
private function statsReader(int $evaluations, int $grades): SubjectGradeStatsReader
{
return new class($evaluations, $grades) implements SubjectGradeStatsReader {
public function __construct(
private int $evaluations,
private int $grades,
) {
}
#[Override]
public function countForSubject(
DomainTenantId $tenantId,
SubjectId $subjectId,
): SubjectGradeStats {
return new SubjectGradeStats($this->evaluations, $this->grades);
}
};
}
private function createProcessor(
bool $granted = true,
?TenantContext $tenantContext = null,
?SubjectGradeStatsReader $statsReader = null,
?Request $request = null,
): DeleteSubjectProcessor {
$archiveHandler = new ArchiveSubjectHandler($this->subjectRepository, $this->clock);
$gradeStatsHandler = new GetSubjectGradeStatsHandler(
$statsReader ?? $this->statsReader(0, 0),
);
$eventBus = $this->createMock(MessageBusInterface::class);
$eventBus->method('dispatch')->willReturnCallback(
static fn (object $message) => new Envelope($message),
);
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')
->with(SubjectVoter::DELETE)
->willReturn($granted);
$requestStack = new RequestStack();
if ($request !== null) {
$requestStack->push($request);
}
return new DeleteSubjectProcessor(
$archiveHandler,
$gradeStatsHandler,
$tenantContext ?? $this->tenantContext,
$eventBus,
$authorizationChecker,
$requestStack,
);
}
}