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

@@ -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');
}
}