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:
@@ -38,6 +38,7 @@ project: classeo
|
||||
project_key: classeo
|
||||
tracking_system: file-system
|
||||
story_location: _bmad-output/implementation-artifacts
|
||||
last_updated: 2026-04-17
|
||||
|
||||
development_status:
|
||||
# Epic 1: Fondations, Auth & Observabilité (9 stories)
|
||||
@@ -121,8 +122,8 @@ development_status:
|
||||
6-6-consultation-notes-par-leleve: done
|
||||
6-7-consultation-notes-par-le-parent: done
|
||||
6-8-statistiques-enseignant: done
|
||||
6-9-grade-voter-et-acces-notes-affectations: review # Débloque tâches différées de 2-6, 2-8, 2-9
|
||||
6-10-statistiques-notes-par-matiere-admin: ready-for-dev # Débloque tâches différées de 2-2
|
||||
6-9-grade-voter-et-acces-notes-affectations: done # Débloque tâches différées de 2-6, 2-8, 2-9
|
||||
6-10-statistiques-notes-par-matiere-admin: review # Débloque tâches différées de 2-2
|
||||
6-11-audit-trail-evenements-notes: ready-for-dev # Débloque tâches différées de 1-7
|
||||
6-12-correctifs-mode-competences: ready-for-dev # Patches critiques review 6-5
|
||||
6-13-acces-evaluations-remplacant: ready-for-dev # UX : navigation évaluations pour le remplaçant (identifié en 6-9)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
174
backend/tests/Functional/Helpers/SubjectStatsSeedingTrait.php
Normal file
174
backend/tests/Functional/Helpers/SubjectStatsSeedingTrait.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -456,21 +456,95 @@ test.describe('Subjects Management (Story 2.2)', () => {
|
||||
// AC3: Deletion with warning for subjects with grades
|
||||
// ============================================================================
|
||||
test.describe('AC3: Deletion with warning for grades', () => {
|
||||
// SKIP REASON: The Grades module is not yet implemented.
|
||||
// HasGradesForSubjectHandler currently returns false (stub), so all subjects
|
||||
// appear without grades and can be deleted without warning. This test will
|
||||
// be enabled once the Grades module allows recording grades for subjects.
|
||||
//
|
||||
// When enabled, this test should:
|
||||
// 1. Create a subject
|
||||
// 2. Add at least one grade to it
|
||||
// 3. Attempt to delete the subject
|
||||
// 4. Verify the warning message about grades
|
||||
// 5. Require explicit confirmation
|
||||
test.skip('shows warning when trying to delete subject with grades', async ({ page }) => {
|
||||
// Subjects seeded by tests in this describe — nettoyés en afterAll via
|
||||
// l'endpoint DELETE /test/seed/subject-with-grades/{subjectId} qui purge
|
||||
// les évaluations et notes associées (le subject lui-même étant soft-deleté
|
||||
// par le flow normal via la modale).
|
||||
const seededSubjectIds: string[] = [];
|
||||
|
||||
// Helper to extract UUIDs from `dbal:run-sql` output — garde pour le
|
||||
// `subjectId` créé par l'UI (une seule requête par test vs. 4 auparavant).
|
||||
function firstUuidFromSql(sql: string): string | null {
|
||||
const output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
const match = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
for (const subjectId of seededSubjectIds) {
|
||||
try {
|
||||
await request.delete(`${ALPHA_URL}/test/seed/subject-with-grades/${subjectId}`);
|
||||
} catch {
|
||||
// Best-effort : l'absence de cleanup ne doit pas faire échouer la suite.
|
||||
}
|
||||
}
|
||||
seededSubjectIds.length = 0;
|
||||
});
|
||||
|
||||
test('shows impact warning with evaluation and grade counts before deletion', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/subjects`);
|
||||
// Implementation pending Grades module
|
||||
|
||||
// Create a subject for which we will seed evaluations/grades
|
||||
await openNewSubjectDialog(page);
|
||||
const subjectName = `WithGrades-${Date.now()}`;
|
||||
const subjectCode = `WG${Date.now() % 10000}`;
|
||||
await page.locator('#subject-name').fill(subjectName);
|
||||
await page.locator('#subject-code').fill(subjectCode);
|
||||
await page.getByRole('button', { name: /créer la matière/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
const subjectId = firstUuidFromSql(
|
||||
`SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' AND code = '${subjectCode.toUpperCase()}' LIMIT 1`
|
||||
);
|
||||
if (!subjectId) {
|
||||
throw new Error('Failed to resolve subjectId');
|
||||
}
|
||||
seededSubjectIds.push(subjectId);
|
||||
|
||||
// Seed classe + 2 évaluations + 2 notes en UN appel HTTP au lieu de 6+
|
||||
// `docker exec dbal:run-sql`. Gain : ~30-60 s → ~5-10 s par test.
|
||||
const seedResponse = await page.request.post(
|
||||
`${ALPHA_URL}/test/seed/subject-with-grades`,
|
||||
{
|
||||
data: {
|
||||
subjectId,
|
||||
teacherEmail: ADMIN_EMAIL,
|
||||
evaluationCount: 2,
|
||||
gradesPerEval: 1
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!seedResponse.ok()) {
|
||||
throw new Error(
|
||||
`Seed endpoint failed: ${seedResponse.status()} ${await seedResponse.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
clearCache();
|
||||
await page.reload();
|
||||
|
||||
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
|
||||
await subjectCard.getByRole('button', { name: /supprimer/i }).click();
|
||||
|
||||
const deleteModal = page.getByRole('alertdialog');
|
||||
await expect(deleteModal).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const impact = deleteModal.getByTestId('delete-subject-impact');
|
||||
await expect(impact).toBeVisible();
|
||||
|
||||
// AC1 exact wording: "X évaluations et Y notes seront affectées"
|
||||
const summary = deleteModal.getByTestId('delete-subject-impact-summary');
|
||||
await expect(summary).toHaveText(/2 évaluations et 2 notes seront affectées\./);
|
||||
|
||||
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
|
||||
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(subjectName)).not.toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
72
frontend/src/lib/features/subjects/api/deleteSubject.ts
Normal file
72
frontend/src/lib/features/subjects/api/deleteSubject.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Logique de suppression d'une matière, extraite du composant Svelte
|
||||
* pour être testable unitairement.
|
||||
*
|
||||
* Flux :
|
||||
* - Si la liste indique `hasGrades === true`, on envoie `?confirm=true` pour forcer
|
||||
* le backend à accepter la suppression (confirmation déjà donnée par l'admin).
|
||||
* - Si `hasGrades` est `null` (stats non chargées) ou `false`, on envoie un DELETE simple :
|
||||
* le backend décidera. En cas de stats obsolètes côté UI, il renverra 409.
|
||||
* - 409 = les stats côté liste étaient périmées ; on renvoie `status: 'conflict'` pour
|
||||
* que l'appelant rafraîchisse la liste et affiche un message explicatif plutôt qu'une
|
||||
* erreur générique.
|
||||
*/
|
||||
|
||||
export interface DeleteSubjectInput {
|
||||
id: string;
|
||||
hasGrades: boolean | null;
|
||||
}
|
||||
|
||||
export type DeleteSubjectResult =
|
||||
| { status: 'success' }
|
||||
| { status: 'conflict'; message: string }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
export type DeleteSubjectFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export async function deleteSubject(
|
||||
subject: DeleteSubjectInput,
|
||||
fetchFn: DeleteSubjectFetch,
|
||||
apiBaseUrl: string
|
||||
): Promise<DeleteSubjectResult> {
|
||||
const url =
|
||||
subject.hasGrades === true
|
||||
? `${apiBaseUrl}/subjects/${subject.id}?confirm=true`
|
||||
: `${apiBaseUrl}/subjects/${subject.id}`;
|
||||
|
||||
const response = await fetchFn(url, { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
const message = await extractErrorMessage(response);
|
||||
|
||||
if (response.status === 409) {
|
||||
return { status: 'conflict', message };
|
||||
}
|
||||
|
||||
return { status: 'error', message };
|
||||
}
|
||||
|
||||
async function extractErrorMessage(response: Response): Promise<string> {
|
||||
const fallback = `Erreur lors de la suppression (${response.status})`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (typeof errorData === 'object' && errorData !== null) {
|
||||
const record = errorData as Record<string, unknown>;
|
||||
if (typeof record['hydra:description'] === 'string') {
|
||||
return record['hydra:description'];
|
||||
}
|
||||
if (typeof record['message'] === 'string') {
|
||||
return record['message'];
|
||||
}
|
||||
if (typeof record['detail'] === 'string') {
|
||||
return record['detail'];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON parsing failed, keep fallback
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { deleteSubject } from '$lib/features/subjects/api/deleteSubject';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
// Types
|
||||
@@ -17,6 +18,9 @@
|
||||
status: string;
|
||||
teacherCount: number | null;
|
||||
classCount: number | null;
|
||||
evaluationCount: number | null;
|
||||
gradeCount: number | null;
|
||||
hasGrades: boolean | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -177,29 +181,26 @@
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (!subjectToDelete) return;
|
||||
if (isDeleting) return;
|
||||
|
||||
try {
|
||||
isDeleting = true;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectToDelete.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await deleteSubject(
|
||||
{ id: subjectToDelete.id, hasGrades: subjectToDelete.hasGrades },
|
||||
authenticatedFetch,
|
||||
getApiBaseUrl()
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Erreur lors de la suppression (${response.status})`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData['hydra:description']) {
|
||||
errorMessage = errorData['hydra:description'];
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.detail) {
|
||||
errorMessage = errorData.detail;
|
||||
}
|
||||
} catch {
|
||||
// JSON parsing failed, keep default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
if (result.status === 'conflict') {
|
||||
// Stats côté UI périmées : rafraîchir la liste pour que l'admin voie l'impact réel.
|
||||
closeDeleteModal();
|
||||
await loadSubjects();
|
||||
error = `${result.message} La liste a été rafraîchie, réessayez pour voir l'impact exact.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'error') {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
closeDeleteModal();
|
||||
@@ -313,6 +314,10 @@
|
||||
<span class="stat-icon">🏫</span>
|
||||
{subject.classCount ?? 0}
|
||||
</span>
|
||||
<span class="stat-item" title="Évaluations créées">
|
||||
<span class="stat-icon">📝</span>
|
||||
{subject.evaluationCount ?? 0}
|
||||
</span>
|
||||
<span class="stat-item status-{subject.status}">
|
||||
{subject.status === 'active' ? 'Active' : 'Archivée'}
|
||||
</span>
|
||||
@@ -448,7 +453,9 @@
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-modal-title"
|
||||
aria-describedby="delete-modal-description"
|
||||
aria-describedby={subjectToDelete.hasGrades
|
||||
? 'delete-modal-description delete-subject-impact'
|
||||
: 'delete-modal-description'}
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
|
||||
@@ -463,6 +470,23 @@
|
||||
Êtes-vous sûr de vouloir supprimer la matière <strong>{subjectToDelete.name}</strong> ({subjectToDelete.code})
|
||||
?
|
||||
</p>
|
||||
{#if subjectToDelete.hasGrades}
|
||||
<div
|
||||
class="delete-impact"
|
||||
id="delete-subject-impact"
|
||||
data-testid="delete-subject-impact"
|
||||
>
|
||||
<p class="delete-impact-title" data-testid="delete-subject-impact-summary">
|
||||
⚠️ <strong>{subjectToDelete.evaluationCount ?? 0}</strong>
|
||||
évaluation{(subjectToDelete.evaluationCount ?? 0) > 1 ? 's' : ''} et
|
||||
<strong>{subjectToDelete.gradeCount ?? 0}</strong>
|
||||
note{(subjectToDelete.gradeCount ?? 0) > 1 ? 's' : ''} seront affectées.
|
||||
</p>
|
||||
<p class="delete-impact-note">
|
||||
Ces données resteront consultables dans l'historique mais la matière ne sera plus sélectionnable.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="delete-warning">Cette action est irréversible.</p>
|
||||
</div>
|
||||
|
||||
@@ -906,4 +930,26 @@
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.delete-impact {
|
||||
margin: 1rem 0 0;
|
||||
padding: 0.875rem 1rem;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.delete-impact-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.delete-impact-note {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #78350f;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { deleteSubject } from '$lib/features/subjects/api/deleteSubject';
|
||||
|
||||
const API = 'http://test.classeo.local:18000/api';
|
||||
|
||||
function makeResponse(status: number, body?: Record<string, unknown>): Response {
|
||||
const init: ResponseInit = { status };
|
||||
if (body !== undefined) {
|
||||
init.headers = { 'Content-Type': 'application/json' };
|
||||
}
|
||||
return new Response(body !== undefined ? JSON.stringify(body) : null, init);
|
||||
}
|
||||
|
||||
describe('deleteSubject', () => {
|
||||
it('envoie DELETE sans confirm quand hasGrades=false', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204));
|
||||
|
||||
const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc`, { method: 'DELETE' });
|
||||
expect(result).toEqual({ status: 'success' });
|
||||
});
|
||||
|
||||
it('envoie DELETE sans confirm quand hasGrades=null (pour laisser le backend décider)', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204));
|
||||
|
||||
await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc`, { method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('ajoute ?confirm=true quand hasGrades=true', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204));
|
||||
|
||||
await deleteSubject({ id: 'abc', hasGrades: true }, fetchFn, API);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc?confirm=true`, { method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('retourne un status conflict avec le message backend sur 409', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValueOnce(
|
||||
makeResponse(409, {
|
||||
'hydra:description':
|
||||
'Cette matière est liée à 3 évaluation(s) et 12 note(s). Confirmez la suppression pour continuer.'
|
||||
})
|
||||
);
|
||||
|
||||
const result = await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'conflict',
|
||||
message:
|
||||
'Cette matière est liée à 3 évaluation(s) et 12 note(s). Confirmez la suppression pour continuer.'
|
||||
});
|
||||
});
|
||||
|
||||
it('retourne un status error sur autre code HTTP', async () => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makeResponse(500, { detail: 'Internal server error' }));
|
||||
|
||||
const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API);
|
||||
|
||||
expect(result).toEqual({ status: 'error', message: 'Internal server error' });
|
||||
});
|
||||
|
||||
it('fallback message si pas de payload JSON', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(403));
|
||||
|
||||
const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API);
|
||||
|
||||
expect(result).toEqual({ status: 'error', message: 'Erreur lors de la suppression (403)' });
|
||||
});
|
||||
|
||||
it('extrait message depuis le champ `message`', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(409, { message: 'Conflit' }));
|
||||
|
||||
const result = await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API);
|
||||
|
||||
expect(result).toEqual({ status: 'conflict', message: 'Conflit' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user