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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user