feat: Permettre l'import d'élèves via fichier CSV ou XLSX
L'import manuel élève par élève est fastidieux pour les établissements qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes (upload → mapping → preview → confirmation) permet de traiter un fichier complet en une seule opération, avec détection automatique du format (Pronote, École Directe) et validation avant import. L'import est traité de manière asynchrone via Messenger pour ne pas bloquer l'interface, avec suivi de progression en temps réel et réutilisation des mappings entre imports successifs.
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\ImportStudents;
|
||||
|
||||
use App\Administration\Application\Command\ImportStudents\ImportStudentsCommand;
|
||||
use App\Administration\Application\Command\ImportStudents\ImportStudentsHandler;
|
||||
use App\Administration\Domain\Model\Import\ColumnMapping;
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\ImportStatus;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryImportBatchRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
final class ImportStudentsHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
|
||||
|
||||
private InMemoryImportBatchRepository $importBatchRepository;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private InMemoryClassRepository $classRepository;
|
||||
private InMemoryClassAssignmentRepository $classAssignmentRepository;
|
||||
private ImportStudentsHandler $handler;
|
||||
private TenantId $tenantId;
|
||||
private AcademicYearId $academicYearId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-24 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$this->academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
|
||||
|
||||
$this->importBatchRepository = new InMemoryImportBatchRepository();
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
|
||||
$this->handler = new ImportStudentsHandler(
|
||||
$this->importBatchRepository,
|
||||
$this->userRepository,
|
||||
$this->classRepository,
|
||||
$this->classAssignmentRepository,
|
||||
new SchoolIdResolver(),
|
||||
$connection,
|
||||
$clock,
|
||||
new NullLogger(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function importsStudentsWithExistingClasses(): void
|
||||
{
|
||||
$class = $this->createClass('6ème A');
|
||||
$batch = $this->createBatchWithRows([
|
||||
$this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'),
|
||||
$this->createMappedRow(2, 'Martin', 'Marie', '6ème A'),
|
||||
]);
|
||||
|
||||
($this->handler)(new ImportStudentsCommand(
|
||||
batchId: (string) $batch->id,
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolName: 'École Test',
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
));
|
||||
|
||||
$updatedBatch = $this->importBatchRepository->get($batch->id);
|
||||
|
||||
self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status);
|
||||
self::assertSame(2, $updatedBatch->importedCount);
|
||||
self::assertSame(0, $updatedBatch->errorCount);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function importsStudentsWithEmail(): void
|
||||
{
|
||||
$this->createClass('6ème A');
|
||||
$batch = $this->createBatchWithRows([
|
||||
$this->createMappedRow(1, 'Dupont', 'Jean', '6ème A', 'jean@test.com'),
|
||||
]);
|
||||
|
||||
($this->handler)(new ImportStudentsCommand(
|
||||
batchId: (string) $batch->id,
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolName: 'École Test',
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
));
|
||||
|
||||
$students = $this->userRepository->findStudentsByTenant($this->tenantId);
|
||||
|
||||
self::assertCount(1, $students);
|
||||
self::assertNotNull($students[0]->email);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function importsStudentsWithoutEmail(): void
|
||||
{
|
||||
$this->createClass('6ème A');
|
||||
$batch = $this->createBatchWithRows([
|
||||
$this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'),
|
||||
]);
|
||||
|
||||
($this->handler)(new ImportStudentsCommand(
|
||||
batchId: (string) $batch->id,
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolName: 'École Test',
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
));
|
||||
|
||||
$students = $this->userRepository->findStudentsByTenant($this->tenantId);
|
||||
|
||||
self::assertCount(1, $students);
|
||||
self::assertNull($students[0]->email);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsMissingClassesWhenEnabled(): void
|
||||
{
|
||||
$batch = $this->createBatchWithRows([
|
||||
$this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'),
|
||||
]);
|
||||
|
||||
($this->handler)(new ImportStudentsCommand(
|
||||
batchId: (string) $batch->id,
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolName: 'École Test',
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
createMissingClasses: true,
|
||||
));
|
||||
|
||||
$updatedBatch = $this->importBatchRepository->get($batch->id);
|
||||
|
||||
self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status);
|
||||
self::assertSame(1, $updatedBatch->importedCount);
|
||||
|
||||
$createdClass = $this->classRepository->findByName(
|
||||
new ClassName('6ème A'),
|
||||
$this->tenantId,
|
||||
$this->academicYearId,
|
||||
);
|
||||
self::assertNotNull($createdClass);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function countsErrorsForMissingClassesWhenNotEnabled(): void
|
||||
{
|
||||
$batch = $this->createBatchWithRows([
|
||||
$this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'),
|
||||
$this->createMappedRow(2, 'Martin', 'Marie', '5ème B'),
|
||||
]);
|
||||
|
||||
($this->handler)(new ImportStudentsCommand(
|
||||
batchId: (string) $batch->id,
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolName: 'École Test',
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
createMissingClasses: false,
|
||||
));
|
||||
|
||||
$updatedBatch = $this->importBatchRepository->get($batch->id);
|
||||
|
||||
self::assertSame(ImportStatus::COMPLETED, $updatedBatch->status);
|
||||
self::assertSame(0, $updatedBatch->importedCount);
|
||||
self::assertSame(2, $updatedBatch->errorCount);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsClassAssignments(): void
|
||||
{
|
||||
$class = $this->createClass('6ème A');
|
||||
$batch = $this->createBatchWithRows([
|
||||
$this->createMappedRow(1, 'Dupont', 'Jean', '6ème A'),
|
||||
]);
|
||||
|
||||
($this->handler)(new ImportStudentsCommand(
|
||||
batchId: (string) $batch->id,
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolName: 'École Test',
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
));
|
||||
|
||||
$students = $this->userRepository->findStudentsByTenant($this->tenantId);
|
||||
self::assertCount(1, $students);
|
||||
|
||||
$assignment = $this->classAssignmentRepository->findByStudent(
|
||||
$students[0]->id,
|
||||
$this->academicYearId,
|
||||
$this->tenantId,
|
||||
);
|
||||
self::assertNotNull($assignment);
|
||||
self::assertTrue($assignment->classId->equals($class->id));
|
||||
}
|
||||
|
||||
private function createClass(string $name): SchoolClass
|
||||
{
|
||||
$class = SchoolClass::creer(
|
||||
tenantId: $this->tenantId,
|
||||
schoolId: SchoolId::generate(),
|
||||
academicYearId: $this->academicYearId,
|
||||
name: new ClassName($name),
|
||||
level: null,
|
||||
capacity: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-01'),
|
||||
);
|
||||
|
||||
$this->classRepository->save($class);
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
*/
|
||||
private function createBatchWithRows(array $rows): StudentImportBatch
|
||||
{
|
||||
$batch = StudentImportBatch::creer(
|
||||
tenantId: $this->tenantId,
|
||||
originalFilename: 'test.csv',
|
||||
totalRows: count($rows),
|
||||
detectedColumns: ['Nom', 'Prénom', 'Classe'],
|
||||
detectedFormat: KnownImportFormat::CUSTOM,
|
||||
createdAt: new DateTimeImmutable('2026-02-24 09:00:00'),
|
||||
);
|
||||
|
||||
$mapping = ColumnMapping::creer(
|
||||
[
|
||||
'Nom' => StudentImportField::LAST_NAME,
|
||||
'Prénom' => StudentImportField::FIRST_NAME,
|
||||
'Classe' => StudentImportField::CLASS_NAME,
|
||||
'Email' => StudentImportField::EMAIL,
|
||||
],
|
||||
KnownImportFormat::CUSTOM,
|
||||
);
|
||||
|
||||
$batch->appliquerMapping($mapping);
|
||||
$batch->enregistrerLignes($rows);
|
||||
$this->importBatchRepository->save($batch);
|
||||
|
||||
return $batch;
|
||||
}
|
||||
|
||||
private function createMappedRow(
|
||||
int $line,
|
||||
string $lastName,
|
||||
string $firstName,
|
||||
string $className,
|
||||
?string $email = null,
|
||||
): ImportRow {
|
||||
$mappedData = [
|
||||
'lastName' => $lastName,
|
||||
'firstName' => $firstName,
|
||||
'className' => $className,
|
||||
];
|
||||
|
||||
if ($email !== null) {
|
||||
$mappedData['email'] = $email;
|
||||
}
|
||||
|
||||
return new ImportRow(
|
||||
lineNumber: $line,
|
||||
rawData: $mappedData,
|
||||
mappedData: $mappedData,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user