feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
This commit is contained in:
@@ -36,11 +36,9 @@ final class PasswordResetEndpointsTest extends ApiTestCase
|
||||
|
||||
// Should NOT return 401 Unauthorized
|
||||
// It should return 200 (success) or 429 (rate limited), but never 401
|
||||
self::assertNotEquals(401, $response->getStatusCode(), 'Password forgot endpoint should be accessible without JWT');
|
||||
|
||||
// The endpoint always returns success to prevent email enumeration
|
||||
// Even for non-existent emails
|
||||
self::assertResponseIsSuccessful();
|
||||
$status = $response->getStatusCode();
|
||||
self::assertNotEquals(401, $status, 'Password forgot endpoint should be accessible without JWT');
|
||||
self::assertContains($status, [200, 201, 429], 'Expected 200/201 (success) or 429 (rate limited)');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Scolarite\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||
use App\Scolarite\Domain\Repository\EvaluationRepository;
|
||||
use App\Scolarite\Domain\Repository\GradeRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
final class TeacherStatisticsEndpointsTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string TEACHER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
private const string STUDENT_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
private const string STUDENT2_ID = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
|
||||
private const string CLASS_ID = 'dddddddd-dddd-dddd-dddd-dddddddddddd';
|
||||
private const string SUBJECT_ID = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee';
|
||||
private const string PERIOD_ID = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
|
||||
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seedFixtures();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
/** @var Connection $connection */
|
||||
$connection = static::getContainer()->get(Connection::class);
|
||||
$connection->executeStatement('DELETE FROM evaluation_statistics WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid AND teacher_id = :teach)', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM student_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM student_general_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid AND created_by = :teach)', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid AND created_by = :teach', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid AND teacher_id = :teach', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM teacher_assignments WHERE tenant_id = :tid AND teacher_id = :teach', ['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM class_assignments WHERE tenant_id = :tid AND school_class_id = :class', ['tid' => self::TENANT_ID, 'class' => self::CLASS_ID]);
|
||||
$connection->executeStatement('DELETE FROM academic_periods WHERE id = :id', ['id' => self::PERIOD_ID]);
|
||||
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::TEACHER_ID]);
|
||||
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::STUDENT_ID]);
|
||||
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::STUDENT2_ID]);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics — Auth & Access
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function overviewReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overviewReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function overviewReturns403ForParent(): void
|
||||
{
|
||||
$parentId = '99999999-9999-9999-9999-999999999999';
|
||||
$client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function overviewReturnsClassSummaryForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('teacherId', $data);
|
||||
self::assertSame(self::TEACHER_ID, $data['teacherId']);
|
||||
self::assertArrayHasKey('classes', $data);
|
||||
self::assertNotEmpty($data['classes']);
|
||||
|
||||
$class = $data['classes'][0];
|
||||
self::assertSame(self::CLASS_ID, $class['classId']);
|
||||
self::assertSame(self::SUBJECT_ID, $class['subjectId']);
|
||||
self::assertArrayHasKey('evaluationCount', $class);
|
||||
self::assertArrayHasKey('studentCount', $class);
|
||||
self::assertArrayHasKey('average', $class);
|
||||
self::assertArrayHasKey('successRate', $class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/classes/{classId} — Auth & Validation
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturns400WithoutSubjectId(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturns400WithInvalidThreshold(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID . '&threshold=25', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/classes/{classId} — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function classDetailReturnsStatisticsForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/classes/' . self::CLASS_ID . '?subjectId=' . self::SUBJECT_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertSame(self::CLASS_ID, $data['classId']);
|
||||
self::assertSame(self::SUBJECT_ID, $data['subjectId']);
|
||||
self::assertArrayHasKey('average', $data);
|
||||
self::assertArrayHasKey('successRate', $data);
|
||||
self::assertArrayHasKey('distribution', $data);
|
||||
self::assertCount(8, $data['distribution']);
|
||||
self::assertArrayHasKey('evolution', $data);
|
||||
self::assertArrayHasKey('students', $data);
|
||||
self::assertNotEmpty($data['students']);
|
||||
|
||||
$student = $data['students'][0];
|
||||
self::assertArrayHasKey('studentId', $student);
|
||||
self::assertArrayHasKey('studentName', $student);
|
||||
self::assertArrayHasKey('average', $student);
|
||||
self::assertArrayHasKey('inDifficulty', $student);
|
||||
self::assertArrayHasKey('trend', $student);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/export — Auth & Validation
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function exportReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID . '&subjectId=' . self::SUBJECT_ID);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exportReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID . '&subjectId=' . self::SUBJECT_ID);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exportReturns400WithoutClassId(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?subjectId=' . self::SUBJECT_ID);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exportReturns400WithoutSubjectId(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/export — Happy path
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function exportReturnsCsvWithCorrectHeaders(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/export?classId=' . self::CLASS_ID . '&subjectId=' . self::SUBJECT_ID . '&className=6eB&subjectName=Math%C3%A9matiques');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertResponseHeaderSame('content-type', 'text/csv; charset=UTF-8');
|
||||
|
||||
/** @var string $csv */
|
||||
$csv = $client->getResponse()->getContent();
|
||||
self::assertNotEmpty($csv);
|
||||
self::assertStringContainsString('Moyenne', $csv);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/evaluations — Auth & Access
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function evaluationDifficultyReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/evaluations', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function evaluationDifficultyReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/evaluations', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function evaluationDifficultyReturnsDataForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/evaluations', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('evaluations', $payload);
|
||||
/** @var list<array<string, mixed>> $evaluations */
|
||||
$evaluations = $payload['evaluations'];
|
||||
self::assertIsArray($evaluations);
|
||||
self::assertNotEmpty($evaluations);
|
||||
|
||||
$eval = $evaluations[0];
|
||||
self::assertArrayHasKey('evaluationId', $eval);
|
||||
self::assertArrayHasKey('title', $eval);
|
||||
self::assertArrayHasKey('gradedCount', $eval);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GET /me/statistics/students/{studentId} — Auth & Validation
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function studentProgressionReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/students/' . self::STUDENT_ID . '?subjectId=' . self::SUBJECT_ID . '&classId=' . self::CLASS_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function studentProgressionReturns403ForStudent(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/students/' . self::STUDENT_ID . '?subjectId=' . self::SUBJECT_ID . '&classId=' . self::CLASS_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function studentProgressionReturnsDataForTeacher(): void
|
||||
{
|
||||
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||
$client->request('GET', self::BASE_URL . '/me/statistics/students/' . self::STUDENT_ID . '?subjectId=' . self::SUBJECT_ID . '&classId=' . self::CLASS_ID, [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
/** @var string $content */
|
||||
$content = $client->getResponse()->getContent();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertArrayHasKey('grades', $data);
|
||||
self::assertIsArray($data['grades']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function createAuthenticatedClient(string $userId, array $roles): \ApiPlatform\Symfony\Bundle\Test\Client
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$user = new SecurityUser(
|
||||
userId: UserId::fromString($userId),
|
||||
email: 'test-stats@classeo.local',
|
||||
hashedPassword: '',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: $roles,
|
||||
);
|
||||
|
||||
$client->loginUser($user, 'api');
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function seedFixtures(): void
|
||||
{
|
||||
$container = static::getContainer();
|
||||
/** @var Connection $connection */
|
||||
$connection = $container->get(Connection::class);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
$schoolId = '550e8400-e29b-41d4-a716-ff6655440001';
|
||||
$academicYearId = '550e8400-e29b-41d4-a716-ff6655440002';
|
||||
|
||||
// Seed users
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||
VALUES (:id, :tid, 'teacher-stats@test.local', '', 'Marc', 'Dupont', '[\"ROLE_PROF\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||
VALUES (:id, :tid, 'student-stats1@test.local', '', 'Alice', 'Durand', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::STUDENT_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||
VALUES (:id, :tid, 'student-stats2@test.local', '', 'Bob', 'Martin', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::STUDENT2_ID, 'tid' => self::TENANT_ID],
|
||||
);
|
||||
|
||||
// Seed class and subject
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at)
|
||||
VALUES (:id, :tid, :sid, :ayid, 'Stats-6eB', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
|
||||
VALUES (:id, :tid, :sid, 'Mathématiques', 'MATH', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||
);
|
||||
|
||||
// Seed academic period (must cover current date for queries to return data)
|
||||
// Clean up any conflicting rows first (unique constraint on tenant_id, academic_year_id, sequence)
|
||||
$connection->executeStatement(
|
||||
'DELETE FROM academic_periods WHERE tenant_id = :tid AND academic_year_id = :ayid AND sequence = 2',
|
||||
['tid' => self::TENANT_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
'DELETE FROM academic_periods WHERE id = :id',
|
||||
['id' => self::PERIOD_ID],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO academic_periods (id, tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date)
|
||||
VALUES (:id, :tid, :ayid, 'trimester', 2, 'Trimestre 2', '2026-01-01', '2026-06-30')",
|
||||
['id' => self::PERIOD_ID, 'tid' => self::TENANT_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
|
||||
// Seed teacher assignment (required for statistics reader queries)
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :teach, :class, :subj, :ayid, 'active', NOW(), NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING",
|
||||
['tid' => self::TENANT_ID, 'teach' => self::TEACHER_ID, 'class' => self::CLASS_ID, 'subj' => self::SUBJECT_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
|
||||
// Seed student class assignments (class_assignments links students to classes)
|
||||
$connection->executeStatement(
|
||||
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :sid, :class, :ayid, NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING',
|
||||
['tid' => self::TENANT_ID, 'sid' => self::STUDENT_ID, 'class' => self::CLASS_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :sid, :class, :ayid, NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING',
|
||||
['tid' => self::TENANT_ID, 'sid' => self::STUDENT2_ID, 'class' => self::CLASS_ID, 'ayid' => $academicYearId],
|
||||
);
|
||||
|
||||
// Create and publish evaluations with grades
|
||||
/** @var EvaluationRepository $evalRepo */
|
||||
$evalRepo = $container->get(EvaluationRepository::class);
|
||||
/** @var GradeRepository $gradeRepo */
|
||||
$gradeRepo = $container->get(GradeRepository::class);
|
||||
|
||||
$eval = Evaluation::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'DS Maths Stats',
|
||||
description: null,
|
||||
evaluationDate: new DateTimeImmutable('2026-03-15'),
|
||||
gradeScale: new GradeScale(20),
|
||||
coefficient: new Coefficient(1.0),
|
||||
now: $now,
|
||||
);
|
||||
$eval->publierNotes($now);
|
||||
$eval->pullDomainEvents();
|
||||
$evalRepo->save($eval);
|
||||
|
||||
foreach ([
|
||||
[self::STUDENT_ID, 15.0],
|
||||
[self::STUDENT2_ID, 8.0],
|
||||
] as [$studentId, $value]) {
|
||||
$grade = Grade::saisir(
|
||||
tenantId: $tenantId,
|
||||
evaluationId: $eval->id,
|
||||
studentId: UserId::fromString($studentId),
|
||||
value: new GradeValue($value),
|
||||
status: GradeStatus::GRADED,
|
||||
gradeScale: new GradeScale(20),
|
||||
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||
now: $now,
|
||||
);
|
||||
$grade->pullDomainEvents();
|
||||
$gradeRepo->save($grade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Shared\Infrastructure\Audit;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Shared\Application\Port\AuditLogger;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* [P1] Functional tests for audit trail infrastructure.
|
||||
*
|
||||
* Verifies that the AuditLogger writes to the real audit_log table
|
||||
* and that entries contain correct metadata.
|
||||
*
|
||||
* @see NFR-S7: Audit trail immutable (qui, quoi, quand)
|
||||
* @see FR90: Tracage actions sensibles
|
||||
*/
|
||||
final class AuditTrailFunctionalTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
private Connection $connection;
|
||||
private AuditLogger $auditLogger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
static::bootKernel();
|
||||
$container = static::getContainer();
|
||||
|
||||
/* @var Connection $connection */
|
||||
$this->connection = $container->get(Connection::class);
|
||||
|
||||
/* @var AuditLogger $auditLogger */
|
||||
$this->auditLogger = $container->get(AuditLogger::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logAuthenticationWritesEntryToAuditLogTable(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: [
|
||||
'email_hash' => hash('sha256', 'test@example.com'),
|
||||
'result' => 'success',
|
||||
'method' => 'password',
|
||||
],
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||
[$userId->toString(), 'ConnexionReussie'],
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry, 'Audit log entry should exist after logAuthentication');
|
||||
self::assertSame('User', $entry['aggregate_type']);
|
||||
self::assertSame($userId->toString(), $entry['aggregate_id']);
|
||||
self::assertSame('ConnexionReussie', $entry['event_type']);
|
||||
|
||||
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame('success', $payload['result']);
|
||||
self::assertSame('password', $payload['method']);
|
||||
self::assertArrayHasKey('email_hash', $payload);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logAuthenticationIncludesMetadataWithTimestamp(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: ['result' => 'success'],
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM audit_log WHERE aggregate_id = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||
[$userId->toString()],
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry);
|
||||
self::assertNotEmpty($entry['occurred_at'], 'Audit entry must have a timestamp');
|
||||
|
||||
$metadata = json_decode($entry['metadata'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertIsArray($metadata);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logFailedAuthenticationWritesWithNullUserId(): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionEchouee',
|
||||
userId: null,
|
||||
payload: [
|
||||
'email_hash' => hash('sha256', 'unknown@example.com'),
|
||||
'result' => 'failure',
|
||||
'reason' => 'invalid_credentials',
|
||||
],
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
"SELECT * FROM audit_log WHERE event_type = 'ConnexionEchouee' ORDER BY occurred_at DESC LIMIT 1",
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry, 'Failed login audit entry should exist');
|
||||
self::assertNull($entry['aggregate_id'], 'Failed login should have null user ID');
|
||||
self::assertSame('User', $entry['aggregate_type']);
|
||||
|
||||
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame('failure', $payload['result']);
|
||||
self::assertSame('invalid_credentials', $payload['reason']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logDataChangeWritesOldAndNewValues(): void
|
||||
{
|
||||
$aggregateId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logDataChange(
|
||||
aggregateType: 'Grade',
|
||||
aggregateId: $aggregateId,
|
||||
eventType: 'GradeModified',
|
||||
oldValues: ['value' => 14.0],
|
||||
newValues: ['value' => 16.0],
|
||||
reason: 'Correction erreur de saisie',
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||
[$aggregateId->toString(), 'GradeModified'],
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry);
|
||||
self::assertSame('Grade', $entry['aggregate_type']);
|
||||
|
||||
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame(['value' => 14.0], $payload['old_values']);
|
||||
self::assertSame(['value' => 16.0], $payload['new_values']);
|
||||
self::assertSame('Correction erreur de saisie', $payload['reason']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function auditLogEntriesAreAppendOnly(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: ['result' => 'success'],
|
||||
);
|
||||
|
||||
$countBefore = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||
[$userId->toString()],
|
||||
);
|
||||
|
||||
self::assertSame(1, $countBefore);
|
||||
|
||||
// Log a second event for the same user
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: ['result' => 'success'],
|
||||
);
|
||||
|
||||
$countAfter = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||
[$userId->toString()],
|
||||
);
|
||||
|
||||
// Both entries should exist (append-only, no overwrite)
|
||||
self::assertSame(2, $countAfter, 'Audit log must be append-only — both entries should exist');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user