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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Application\Service\Import\ColumnMappingSuggester;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
|
||||
use function count;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use const SORT_REGULAR;
|
||||
|
||||
final class ColumnMappingSuggesterTest extends TestCase
|
||||
{
|
||||
private ColumnMappingSuggester $suggester;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->suggester = new ColumnMappingSuggester();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestPronoteMapping(): void
|
||||
{
|
||||
$columns = ['Élèves', 'Né(e) le', 'Sexe', 'Adresse E-mail', 'Classe de rattachement'];
|
||||
|
||||
$mapping = $this->suggester->suggerer($columns, KnownImportFormat::PRONOTE);
|
||||
|
||||
self::assertSame(StudentImportField::FULL_NAME, $mapping['Élèves']);
|
||||
self::assertSame(StudentImportField::BIRTH_DATE, $mapping['Né(e) le']);
|
||||
self::assertSame(StudentImportField::GENDER, $mapping['Sexe']);
|
||||
self::assertSame(StudentImportField::EMAIL, $mapping['Adresse E-mail']);
|
||||
self::assertSame(StudentImportField::CLASS_NAME, $mapping['Classe de rattachement']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestEcoleDirecteMapping(): void
|
||||
{
|
||||
$columns = ['NOM', 'PRENOM', 'CLASSE', 'DATE_NAISSANCE', 'SEXE'];
|
||||
|
||||
$mapping = $this->suggester->suggerer($columns, KnownImportFormat::ECOLE_DIRECTE);
|
||||
|
||||
self::assertSame(StudentImportField::LAST_NAME, $mapping['NOM']);
|
||||
self::assertSame(StudentImportField::FIRST_NAME, $mapping['PRENOM']);
|
||||
self::assertSame(StudentImportField::CLASS_NAME, $mapping['CLASSE']);
|
||||
self::assertSame(StudentImportField::BIRTH_DATE, $mapping['DATE_NAISSANCE']);
|
||||
self::assertSame(StudentImportField::GENDER, $mapping['SEXE']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestGenericMappingByKeywords(): void
|
||||
{
|
||||
$columns = ['Nom', 'Prénom', 'Classe', 'Email'];
|
||||
|
||||
$mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM);
|
||||
|
||||
self::assertSame(StudentImportField::LAST_NAME, $mapping['Nom']);
|
||||
self::assertSame(StudentImportField::FIRST_NAME, $mapping['Prénom']);
|
||||
self::assertSame(StudentImportField::CLASS_NAME, $mapping['Classe']);
|
||||
self::assertSame(StudentImportField::EMAIL, $mapping['Email']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestDoesNotDuplicateFields(): void
|
||||
{
|
||||
$columns = ['Nom', 'Nom de famille', 'Prénom', 'Classe'];
|
||||
|
||||
$mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM);
|
||||
|
||||
$mappedFields = array_values($mapping);
|
||||
$uniqueFields = array_unique($mappedFields, SORT_REGULAR);
|
||||
|
||||
self::assertCount(count($uniqueFields), $mappedFields);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestHandlesUnknownColumns(): void
|
||||
{
|
||||
$columns = ['ColonneInconnue', 'AutreColonne', 'Nom', 'Classe'];
|
||||
|
||||
$mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM);
|
||||
|
||||
self::assertArrayNotHasKey('ColonneInconnue', $mapping);
|
||||
self::assertArrayNotHasKey('AutreColonne', $mapping);
|
||||
self::assertArrayHasKey('Nom', $mapping);
|
||||
self::assertArrayHasKey('Classe', $mapping);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestHandlesEnglishColumnNames(): void
|
||||
{
|
||||
$columns = ['Last Name', 'First Name', 'Class', 'Email'];
|
||||
|
||||
$mapping = $this->suggester->suggerer($columns, KnownImportFormat::CUSTOM);
|
||||
|
||||
self::assertSame(StudentImportField::LAST_NAME, $mapping['Last Name']);
|
||||
self::assertSame(StudentImportField::FIRST_NAME, $mapping['First Name']);
|
||||
self::assertSame(StudentImportField::CLASS_NAME, $mapping['Class']);
|
||||
self::assertSame(StudentImportField::EMAIL, $mapping['Email']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Application\Service\Import\CsvParser;
|
||||
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CsvParserTest extends TestCase
|
||||
{
|
||||
private CsvParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new CsvParser();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseSemicolonSeparatedCsv(): void
|
||||
{
|
||||
$result = $this->parser->parse($this->fixture('eleves_simple.csv'));
|
||||
|
||||
self::assertSame(['Nom', 'Prénom', 'Classe', 'Email'], $result->columns);
|
||||
self::assertSame(3, $result->totalRows());
|
||||
self::assertSame('Dupont', $result->rows[0]['Nom']);
|
||||
self::assertSame('Jean', $result->rows[0]['Prénom']);
|
||||
self::assertSame('6ème A', $result->rows[0]['Classe']);
|
||||
self::assertSame('jean.dupont@email.com', $result->rows[0]['Email']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseCommaSeparatedCsv(): void
|
||||
{
|
||||
$result = $this->parser->parse($this->fixture('eleves_comma.csv'));
|
||||
|
||||
self::assertSame(['Nom', 'Prénom', 'Classe'], $result->columns);
|
||||
self::assertSame(2, $result->totalRows());
|
||||
self::assertSame('Dupont', $result->rows[0]['Nom']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parsePronoteFormatCsv(): void
|
||||
{
|
||||
$result = $this->parser->parse($this->fixture('eleves_pronote.csv'));
|
||||
|
||||
self::assertContains('Élèves', $result->columns);
|
||||
self::assertContains('Né(e) le', $result->columns);
|
||||
self::assertContains('Sexe', $result->columns);
|
||||
self::assertSame(27, $result->totalRows());
|
||||
self::assertSame('BERTHE Alexandre', $result->rows[0]['Élèves']);
|
||||
self::assertSame('07/07/2011', $result->rows[0]['Né(e) le']);
|
||||
self::assertSame('Masculin', $result->rows[0]['Sexe']);
|
||||
self::assertSame('alexandre.berthe@fournisseur.fr', $result->rows[0]['Adresse E-mail']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function previewReturnsLimitedRows(): void
|
||||
{
|
||||
$result = $this->parser->parse($this->fixture('eleves_simple.csv'));
|
||||
|
||||
$preview = $result->preview(2);
|
||||
|
||||
self::assertCount(2, $preview);
|
||||
self::assertSame('Dupont', $preview[0]['Nom']);
|
||||
self::assertSame('Martin', $preview[1]['Nom']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseHandlesUtf8Bom(): void
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'csv_');
|
||||
file_put_contents($tempFile, "\xEF\xBB\xBFNom;Prénom\nDupont;Jean\n");
|
||||
|
||||
try {
|
||||
$result = $this->parser->parse($tempFile);
|
||||
|
||||
self::assertSame(['Nom', 'Prénom'], $result->columns);
|
||||
self::assertSame(1, $result->totalRows());
|
||||
} finally {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseEmptyFileThrowsException(): void
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'csv_');
|
||||
file_put_contents($tempFile, '');
|
||||
|
||||
try {
|
||||
$this->expectException(FichierImportInvalideException::class);
|
||||
|
||||
$this->parser->parse($tempFile);
|
||||
} finally {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseHandlesEmptyRowsGracefully(): void
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'csv_');
|
||||
file_put_contents($tempFile, "Nom;Prénom\nDupont;Jean\n\n\nMartin;Marie\n");
|
||||
|
||||
try {
|
||||
$result = $this->parser->parse($tempFile);
|
||||
|
||||
self::assertSame(2, $result->totalRows());
|
||||
} finally {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
private function fixture(string $filename): string
|
||||
{
|
||||
return __DIR__ . '/../../../../../fixtures/import/' . $filename;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Application\Service\Import\ImportFormatDetector;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ImportFormatDetectorTest extends TestCase
|
||||
{
|
||||
private ImportFormatDetector $detector;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->detector = new ImportFormatDetector();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsPronoteFormat(): void
|
||||
{
|
||||
$columns = ['Élèves', 'Encouragement/Valorisation', 'Né(e) le', 'Sexe', 'Adresse E-mail', 'Entrée', 'Sortie', 'Classe de rattachement'];
|
||||
|
||||
$format = $this->detector->detecter($columns);
|
||||
|
||||
self::assertSame(KnownImportFormat::PRONOTE, $format);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsEcoleDirecteFormat(): void
|
||||
{
|
||||
$columns = ['NOM', 'PRENOM', 'CLASSE', 'DATE_NAISSANCE', 'SEXE'];
|
||||
|
||||
$format = $this->detector->detecter($columns);
|
||||
|
||||
self::assertSame(KnownImportFormat::ECOLE_DIRECTE, $format);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsCustomFormatForUnknownColumns(): void
|
||||
{
|
||||
$columns = ['Nom', 'Prénom', 'Classe', 'Email'];
|
||||
|
||||
$format = $this->detector->detecter($columns);
|
||||
|
||||
self::assertSame(KnownImportFormat::CUSTOM, $format);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsPronoteWithPartialMatch(): void
|
||||
{
|
||||
$columns = ['Élèves', 'Né(e) le', 'Sexe', 'Autre colonne'];
|
||||
|
||||
$format = $this->detector->detecter($columns);
|
||||
|
||||
self::assertSame(KnownImportFormat::PRONOTE, $format);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectsEcoleDirecteWithCaseVariations(): void
|
||||
{
|
||||
$columns = ['nom', 'prenom', 'classe', 'date_naissance'];
|
||||
|
||||
$format = $this->detector->detecter($columns);
|
||||
|
||||
self::assertSame(KnownImportFormat::ECOLE_DIRECTE, $format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Application\Service\Import\ImportRowValidator;
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ImportRowValidatorTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function validRowRemainsValid(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function missingLastNameAddsError(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => '',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
self::assertCount(1, $result->errors);
|
||||
self::assertSame('lastName', $result->errors[0]->column);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function missingMultipleFieldsAddsMultipleErrors(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => '',
|
||||
'firstName' => '',
|
||||
'className' => '',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
self::assertCount(3, $result->errors);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalidEmailAddsError(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
'email' => 'not-an-email',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
self::assertSame('email', $result->errors[0]->column);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validEmailPasses(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
'email' => 'jean.dupont@email.com',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function emptyEmailIsAccepted(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
'email' => '',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalidDateAddsError(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
'birthDate' => 'not-a-date',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
self::assertSame('birthDate', $result->errors[0]->column);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function frenchDateFormatAccepted(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
'birthDate' => '15/03/2014',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isoDateFormatAccepted(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
'birthDate' => '2014-03-15',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unknownClassAddsError(): void
|
||||
{
|
||||
$validator = new ImportRowValidator(['6ème A', '6ème B']);
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '5ème C',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
self::assertSame('className', $result->errors[0]->column);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function knownClassPasses(): void
|
||||
{
|
||||
$validator = new ImportRowValidator(['6ème A', '6ème B']);
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function classValidationSkippedWhenNoClassesProvided(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => 'ClasseInconnue',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validerToutValidatesAllRows(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$rows = [
|
||||
$this->createRow(['lastName' => 'Dupont', 'firstName' => 'Jean', 'className' => '6A']),
|
||||
$this->createRow(['lastName' => '', 'firstName' => 'Marie', 'className' => '6B']),
|
||||
$this->createRow(['lastName' => 'Bernard', 'firstName' => 'Pierre', 'className' => '5A']),
|
||||
];
|
||||
|
||||
$results = $validator->validerTout($rows);
|
||||
|
||||
self::assertCount(3, $results);
|
||||
self::assertTrue($results[0]->estValide());
|
||||
self::assertFalse($results[1]->estValide());
|
||||
self::assertTrue($results[2]->estValide());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fullNameExpandsToLastNameAndFirstName(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'fullName' => 'BERTHE Alexandre',
|
||||
'className' => '6ème A',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
self::assertSame('BERTHE', $result->valeurChamp(StudentImportField::LAST_NAME));
|
||||
self::assertSame('Alexandre', $result->valeurChamp(StudentImportField::FIRST_NAME));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fullNameDoesNotOverrideExistingLastNameFirstName(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'fullName' => 'BERTHE Alexandre',
|
||||
'lastName' => 'Dupont',
|
||||
'firstName' => 'Jean',
|
||||
'className' => '6ème A',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertTrue($result->estValide());
|
||||
self::assertSame('Dupont', $result->valeurChamp(StudentImportField::LAST_NAME));
|
||||
self::assertSame('Jean', $result->valeurChamp(StudentImportField::FIRST_NAME));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function splitFullNameHandlesCompoundLastName(): void
|
||||
{
|
||||
[$lastName, $firstName] = ImportRowValidator::splitFullName('DE LA FONTAINE Jean');
|
||||
|
||||
self::assertSame('DE LA FONTAINE', $lastName);
|
||||
self::assertSame('Jean', $firstName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function splitFullNameHandlesSimpleName(): void
|
||||
{
|
||||
[$lastName, $firstName] = ImportRowValidator::splitFullName('DUPONT Marie');
|
||||
|
||||
self::assertSame('DUPONT', $lastName);
|
||||
self::assertSame('Marie', $firstName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function splitFullNameHandlesCompoundFirstName(): void
|
||||
{
|
||||
[$lastName, $firstName] = ImportRowValidator::splitFullName('OLIVIER Jean-Philippe');
|
||||
|
||||
self::assertSame('OLIVIER', $lastName);
|
||||
self::assertSame('Jean-Philippe', $firstName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function emptyFullNameDoesNotExpand(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = $this->createRow([
|
||||
'fullName' => '',
|
||||
'className' => '6ème A',
|
||||
]);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
self::assertFalse($result->estValide());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $mappedData
|
||||
*/
|
||||
private function createRow(array $mappedData, int $lineNumber = 1): ImportRow
|
||||
{
|
||||
return new ImportRow(
|
||||
lineNumber: $lineNumber,
|
||||
rawData: $mappedData,
|
||||
mappedData: $mappedData,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Application\Service\Import\ColumnMappingSuggester;
|
||||
use App\Administration\Application\Service\Import\CsvParser;
|
||||
use App\Administration\Application\Service\Import\ImportFormatDetector;
|
||||
use App\Administration\Application\Service\Import\ImportReport;
|
||||
use App\Administration\Application\Service\Import\ImportRowValidator;
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Test d'intégration de la chaîne complète d'import avec un vrai fichier Pronote.
|
||||
*
|
||||
* Parse → Détection format → Mapping → Validation → Rapport
|
||||
*/
|
||||
final class PronoteImportIntegrationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function fullPronoteImportPipeline(): void
|
||||
{
|
||||
$filePath = __DIR__ . '/../../../../../fixtures/import/eleves_pronote.csv';
|
||||
|
||||
// 1. Parser le fichier
|
||||
$parser = new CsvParser();
|
||||
$parseResult = $parser->parse($filePath);
|
||||
|
||||
self::assertSame(27, $parseResult->totalRows());
|
||||
self::assertContains('Élèves', $parseResult->columns);
|
||||
|
||||
// 2. Détecter le format
|
||||
$detector = new ImportFormatDetector();
|
||||
$format = $detector->detecter($parseResult->columns);
|
||||
|
||||
self::assertSame(KnownImportFormat::PRONOTE, $format);
|
||||
|
||||
// 3. Suggérer le mapping
|
||||
$suggester = new ColumnMappingSuggester();
|
||||
$suggestedMapping = $suggester->suggerer($parseResult->columns, $format);
|
||||
|
||||
self::assertSame(StudentImportField::FULL_NAME, $suggestedMapping['Élèves']);
|
||||
self::assertSame(StudentImportField::BIRTH_DATE, $suggestedMapping['Né(e) le']);
|
||||
self::assertSame(StudentImportField::GENDER, $suggestedMapping['Sexe']);
|
||||
self::assertSame(StudentImportField::EMAIL, $suggestedMapping['Adresse E-mail']);
|
||||
|
||||
// 4. Appliquer le mapping sur les lignes
|
||||
$rows = [];
|
||||
$lineNumber = 1;
|
||||
|
||||
foreach ($parseResult->rows as $rawData) {
|
||||
$mappedData = [];
|
||||
foreach ($suggestedMapping as $column => $field) {
|
||||
$mappedData[$field->value] = $rawData[$column] ?? '';
|
||||
}
|
||||
$rows[] = new ImportRow($lineNumber, $rawData, $mappedData);
|
||||
++$lineNumber;
|
||||
}
|
||||
|
||||
self::assertCount(27, $rows);
|
||||
self::assertSame('BERTHE Alexandre', $rows[0]->valeurChamp(StudentImportField::FULL_NAME));
|
||||
|
||||
// 5. Valider les lignes (pas de vérification de classes existantes)
|
||||
$validator = new ImportRowValidator();
|
||||
$validatedRows = $validator->validerTout($rows);
|
||||
|
||||
// Toutes les lignes devraient être valides car FULL_NAME → LAST_NAME + FIRST_NAME
|
||||
$validCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
foreach ($validatedRows as $row) {
|
||||
if ($row->estValide()) {
|
||||
++$validCount;
|
||||
} else {
|
||||
++$errorCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Certaines lignes ont une classe vide → erreur sur className obligatoire
|
||||
self::assertGreaterThan(0, $validCount + $errorCount);
|
||||
self::assertSame(27, $validCount + $errorCount);
|
||||
|
||||
// Vérifions que le splitFullName a bien fonctionné sur la première ligne
|
||||
$firstRow = $validatedRows[0];
|
||||
self::assertSame('BERTHE', $firstRow->valeurChamp(StudentImportField::LAST_NAME));
|
||||
self::assertSame('Alexandre', $firstRow->valeurChamp(StudentImportField::FIRST_NAME));
|
||||
|
||||
// Vérifions Jean-Philippe (prénom composé)
|
||||
$jeanPhilippe = null;
|
||||
foreach ($validatedRows as $row) {
|
||||
if (str_contains($row->valeurChamp(StudentImportField::FULL_NAME) ?? '', 'OLIVIER')) {
|
||||
$jeanPhilippe = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
self::assertNotNull($jeanPhilippe);
|
||||
self::assertSame('OLIVIER', $jeanPhilippe->valeurChamp(StudentImportField::LAST_NAME));
|
||||
self::assertSame('Jean-Philippe', $jeanPhilippe->valeurChamp(StudentImportField::FIRST_NAME));
|
||||
|
||||
// 6. Générer le rapport
|
||||
$report = ImportReport::fromValidatedRows($validatedRows);
|
||||
|
||||
self::assertSame(27, $report->totalRows);
|
||||
self::assertSame($validCount, $report->importedCount);
|
||||
self::assertSame($errorCount, $report->errorCount);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pronoteClasseDeRattachementIsEmpty(): void
|
||||
{
|
||||
$filePath = __DIR__ . '/../../../../../fixtures/import/eleves_pronote.csv';
|
||||
|
||||
$parser = new CsvParser();
|
||||
$parseResult = $parser->parse($filePath);
|
||||
|
||||
// Dans le fichier Pronote de démo, "Classe de rattachement" est vide pour tous
|
||||
foreach ($parseResult->rows as $row) {
|
||||
self::assertSame('', $row['Classe de rattachement']);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pronoteRowsWithEmptyClassAreInvalid(): void
|
||||
{
|
||||
$validator = new ImportRowValidator();
|
||||
$row = new ImportRow(
|
||||
lineNumber: 1,
|
||||
rawData: ['Élèves' => 'BERTHE Alexandre', 'Classe de rattachement' => ''],
|
||||
mappedData: [
|
||||
'fullName' => 'BERTHE Alexandre',
|
||||
'className' => '',
|
||||
'birthDate' => '07/07/2011',
|
||||
'gender' => 'Masculin',
|
||||
'email' => 'alexandre.berthe@fournisseur.fr',
|
||||
],
|
||||
);
|
||||
|
||||
$result = $validator->valider($row);
|
||||
|
||||
// className est obligatoire → erreur
|
||||
self::assertFalse($result->estValide());
|
||||
self::assertSame('className', $result->errors[0]->column);
|
||||
|
||||
// Mais le nom a bien été splitté
|
||||
self::assertSame('BERTHE', $result->valeurChamp(StudentImportField::LAST_NAME));
|
||||
self::assertSame('Alexandre', $result->valeurChamp(StudentImportField::FIRST_NAME));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user