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:
2026-02-25 16:51:13 +01:00
parent 560b941821
commit 2420e35492
62 changed files with 7510 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Import;
use App\Administration\Domain\Exception\MappingIncompletException;
use App\Administration\Domain\Model\Import\ColumnMapping;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportField;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ColumnMappingTest extends TestCase
{
#[Test]
public function creerWithAllRequiredFieldsSucceeds(): void
{
$mapping = ColumnMapping::creer(
[
'Nom' => StudentImportField::LAST_NAME,
'Prénom' => StudentImportField::FIRST_NAME,
'Classe' => StudentImportField::CLASS_NAME,
],
KnownImportFormat::CUSTOM,
);
self::assertCount(3, $mapping->colonnesSources());
self::assertSame(KnownImportFormat::CUSTOM, $mapping->format);
}
#[Test]
public function creerWithOptionalFieldsSucceeds(): void
{
$mapping = ColumnMapping::creer(
[
'Nom' => StudentImportField::LAST_NAME,
'Prénom' => StudentImportField::FIRST_NAME,
'Classe' => StudentImportField::CLASS_NAME,
'Email' => StudentImportField::EMAIL,
'Naissance' => StudentImportField::BIRTH_DATE,
],
KnownImportFormat::PRONOTE,
);
self::assertCount(5, $mapping->colonnesSources());
}
#[Test]
public function creerSansNomLeveException(): void
{
$this->expectException(MappingIncompletException::class);
ColumnMapping::creer(
[
'Prénom' => StudentImportField::FIRST_NAME,
'Classe' => StudentImportField::CLASS_NAME,
],
KnownImportFormat::CUSTOM,
);
}
#[Test]
public function creerSansPrenomLeveException(): void
{
$this->expectException(MappingIncompletException::class);
ColumnMapping::creer(
[
'Nom' => StudentImportField::LAST_NAME,
'Classe' => StudentImportField::CLASS_NAME,
],
KnownImportFormat::CUSTOM,
);
}
#[Test]
public function creerSansClasseLeveException(): void
{
$this->expectException(MappingIncompletException::class);
ColumnMapping::creer(
[
'Nom' => StudentImportField::LAST_NAME,
'Prénom' => StudentImportField::FIRST_NAME,
],
KnownImportFormat::CUSTOM,
);
}
#[Test]
public function champPourReturnsMappedField(): void
{
$mapping = ColumnMapping::creer(
[
'Nom' => StudentImportField::LAST_NAME,
'Prénom' => StudentImportField::FIRST_NAME,
'Classe' => StudentImportField::CLASS_NAME,
],
KnownImportFormat::CUSTOM,
);
self::assertSame(StudentImportField::LAST_NAME, $mapping->champPour('Nom'));
self::assertSame(StudentImportField::FIRST_NAME, $mapping->champPour('Prénom'));
self::assertNull($mapping->champPour('Inconnu'));
}
#[Test]
public function equalsComparesCorrectly(): void
{
$mapping1 = ColumnMapping::creer(
['Nom' => StudentImportField::LAST_NAME, 'Prénom' => StudentImportField::FIRST_NAME, 'Classe' => StudentImportField::CLASS_NAME],
KnownImportFormat::CUSTOM,
);
$mapping2 = ColumnMapping::creer(
['Nom' => StudentImportField::LAST_NAME, 'Prénom' => StudentImportField::FIRST_NAME, 'Classe' => StudentImportField::CLASS_NAME],
KnownImportFormat::CUSTOM,
);
$mapping3 = ColumnMapping::creer(
['Nom' => StudentImportField::LAST_NAME, 'Prénom' => StudentImportField::FIRST_NAME, 'Classe' => StudentImportField::CLASS_NAME],
KnownImportFormat::PRONOTE,
);
self::assertTrue($mapping1->equals($mapping2));
self::assertFalse($mapping1->equals($mapping3));
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\StudentImportField;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ImportRowTest extends TestCase
{
#[Test]
public function rowSansErreurEstValide(): void
{
$row = new ImportRow(
lineNumber: 1,
rawData: ['Nom' => 'Dupont', 'Prénom' => 'Jean'],
mappedData: ['lastName' => 'Dupont', 'firstName' => 'Jean'],
);
self::assertTrue($row->estValide());
self::assertSame(1, $row->lineNumber);
}
#[Test]
public function rowAvecErreursEstInvalide(): void
{
$row = new ImportRow(
lineNumber: 3,
rawData: ['Nom' => '', 'Prénom' => 'Jean'],
mappedData: ['lastName' => '', 'firstName' => 'Jean'],
errors: [new ImportRowError('lastName', 'Le nom est obligatoire')],
);
self::assertFalse($row->estValide());
self::assertCount(1, $row->errors);
}
#[Test]
public function valeurChampReturnsMappedValue(): void
{
$row = new ImportRow(
lineNumber: 1,
rawData: [],
mappedData: ['lastName' => 'Dupont', 'firstName' => 'Jean'],
);
self::assertSame('Dupont', $row->valeurChamp(StudentImportField::LAST_NAME));
self::assertSame('Jean', $row->valeurChamp(StudentImportField::FIRST_NAME));
self::assertNull($row->valeurChamp(StudentImportField::EMAIL));
}
#[Test]
public function avecErreursCreatesNewRowWithAdditionalErrors(): void
{
$row = new ImportRow(
lineNumber: 1,
rawData: [],
mappedData: ['lastName' => ''],
errors: [new ImportRowError('lastName', 'Le nom est obligatoire')],
);
$newRow = $row->avecErreurs(
new ImportRowError('email', 'Email invalide'),
);
self::assertCount(1, $row->errors);
self::assertCount(2, $newRow->errors);
self::assertSame($row->lineNumber, $newRow->lineNumber);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Import;
use App\Administration\Domain\Model\Import\ImportStatus;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ImportStatusTest extends TestCase
{
#[Test]
public function peutDemarrerOnlyForPending(): void
{
self::assertTrue(ImportStatus::PENDING->peutDemarrer());
self::assertFalse(ImportStatus::PROCESSING->peutDemarrer());
self::assertFalse(ImportStatus::COMPLETED->peutDemarrer());
self::assertFalse(ImportStatus::FAILED->peutDemarrer());
}
#[Test]
public function estTermineForCompletedAndFailed(): void
{
self::assertFalse(ImportStatus::PENDING->estTermine());
self::assertFalse(ImportStatus::PROCESSING->estTermine());
self::assertTrue(ImportStatus::COMPLETED->estTermine());
self::assertTrue(ImportStatus::FAILED->estTermine());
}
#[Test]
public function labelReturnsReadableText(): void
{
self::assertSame('En attente', ImportStatus::PENDING->label());
self::assertSame('En cours', ImportStatus::PROCESSING->label());
self::assertSame('Terminé', ImportStatus::COMPLETED->label());
self::assertSame('Échoué', ImportStatus::FAILED->label());
}
}

View File

@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Import;
use App\Administration\Domain\Event\ImportElevesLance;
use App\Administration\Domain\Event\ImportElevesTermine;
use App\Administration\Domain\Exception\ImportNonDemarrableException;
use App\Administration\Domain\Model\Import\ColumnMapping;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class StudentImportBatchTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
#[Test]
public function creerCreatesBatchWithPendingStatus(): void
{
$batch = $this->createBatch();
self::assertSame(ImportStatus::PENDING, $batch->status);
self::assertSame(0, $batch->importedCount);
self::assertSame(0, $batch->errorCount);
self::assertNull($batch->completedAt);
self::assertNull($batch->mapping);
}
#[Test]
public function creerSetsAllProperties(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$createdAt = new DateTimeImmutable('2026-02-24 10:00:00');
$columns = ['Nom', 'Prénom', 'Classe'];
$batch = StudentImportBatch::creer(
tenantId: $tenantId,
originalFilename: 'eleves.csv',
totalRows: 50,
detectedColumns: $columns,
detectedFormat: KnownImportFormat::PRONOTE,
createdAt: $createdAt,
);
self::assertTrue($batch->tenantId->equals($tenantId));
self::assertSame('eleves.csv', $batch->originalFilename);
self::assertSame(50, $batch->totalRows);
self::assertSame($columns, $batch->detectedColumns);
self::assertSame(KnownImportFormat::PRONOTE, $batch->detectedFormat);
self::assertEquals($createdAt, $batch->createdAt);
}
#[Test]
public function creerDoesNotRecordAnyEvent(): void
{
$batch = $this->createBatch();
self::assertEmpty($batch->pullDomainEvents());
}
#[Test]
public function appliquerMappingSetsMapping(): void
{
$batch = $this->createBatch();
$mapping = $this->createValidMapping();
$batch->appliquerMapping($mapping);
self::assertNotNull($batch->mapping);
self::assertTrue($batch->mapping->equals($mapping));
}
#[Test]
public function enregistrerLignesStoresRows(): void
{
$batch = $this->createBatch();
$rows = [
new ImportRow(1, ['Nom' => 'Dupont'], ['lastName' => 'Dupont']),
new ImportRow(2, ['Nom' => 'Martin'], ['lastName' => 'Martin']),
];
$batch->enregistrerLignes($rows);
self::assertCount(2, $batch->lignes());
self::assertSame(50, $batch->totalRows);
}
#[Test]
public function demarrerTransitionsToProcessingAndRecordsEvent(): void
{
$batch = $this->createBatch();
$batch->appliquerMapping($this->createValidMapping());
$at = new DateTimeImmutable('2026-02-24 11:00:00');
$batch->demarrer($at);
self::assertSame(ImportStatus::PROCESSING, $batch->status);
$events = $batch->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ImportElevesLance::class, $events[0]);
self::assertTrue($events[0]->batchId->equals($batch->id));
self::assertTrue($events[0]->tenantId->equals($batch->tenantId));
}
#[Test]
public function demarrerSansMappingLeveException(): void
{
$batch = $this->createBatch();
$this->expectException(ImportNonDemarrableException::class);
$batch->demarrer(new DateTimeImmutable());
}
#[Test]
public function demarrerDepuisStatutNonPendingLeveException(): void
{
$batch = $this->createBatch();
$batch->appliquerMapping($this->createValidMapping());
$batch->demarrer(new DateTimeImmutable());
$this->expectException(ImportNonDemarrableException::class);
$batch->demarrer(new DateTimeImmutable());
}
#[Test]
public function terminerSetsCompletedStatusAndRecordsEvent(): void
{
$batch = $this->createBatch();
$batch->appliquerMapping($this->createValidMapping());
$batch->demarrer(new DateTimeImmutable());
$batch->pullDomainEvents();
$at = new DateTimeImmutable('2026-02-24 12:00:00');
$batch->terminer(45, 5, $at);
self::assertSame(ImportStatus::COMPLETED, $batch->status);
self::assertSame(45, $batch->importedCount);
self::assertSame(5, $batch->errorCount);
self::assertEquals($at, $batch->completedAt);
self::assertTrue($batch->estTermine());
$events = $batch->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ImportElevesTermine::class, $events[0]);
self::assertSame(45, $events[0]->importedCount);
self::assertSame(5, $events[0]->errorCount);
}
#[Test]
public function echouerSetsFailedStatus(): void
{
$batch = $this->createBatch();
$batch->appliquerMapping($this->createValidMapping());
$batch->demarrer(new DateTimeImmutable());
$batch->pullDomainEvents();
$at = new DateTimeImmutable('2026-02-24 12:00:00');
$batch->echouer(50, $at);
self::assertSame(ImportStatus::FAILED, $batch->status);
self::assertSame(50, $batch->errorCount);
self::assertEquals($at, $batch->completedAt);
self::assertTrue($batch->estTermine());
}
#[Test]
public function lignesValidesFiltersCorrectly(): void
{
$batch = $this->createBatch();
$rows = [
new ImportRow(1, [], ['lastName' => 'Dupont']),
new ImportRow(2, [], ['lastName' => ''], [new ImportRowError('lastName', 'Nom vide')]),
new ImportRow(3, [], ['lastName' => 'Martin']),
];
$batch->enregistrerLignes($rows);
self::assertCount(2, $batch->lignesValides());
self::assertCount(1, $batch->lignesEnErreur());
}
#[Test]
public function progressionCalculatesCorrectly(): void
{
$batch = $this->createBatch();
$batch->appliquerMapping($this->createValidMapping());
$batch->demarrer(new DateTimeImmutable());
$batch->terminer(40, 10, new DateTimeImmutable());
self::assertEqualsWithDelta(100.0, $batch->progression(), 0.01);
}
#[Test]
public function progressionReturnsZeroForEmptyBatch(): void
{
$batch = StudentImportBatch::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
originalFilename: 'empty.csv',
totalRows: 0,
detectedColumns: [],
detectedFormat: null,
createdAt: new DateTimeImmutable(),
);
self::assertEqualsWithDelta(0.0, $batch->progression(), 0.01);
}
#[Test]
public function reconstituteRestoresAllProperties(): void
{
$id = \App\Administration\Domain\Model\Import\ImportBatchId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$mapping = $this->createValidMapping();
$createdAt = new DateTimeImmutable('2026-02-24 10:00:00');
$completedAt = new DateTimeImmutable('2026-02-24 12:00:00');
$batch = StudentImportBatch::reconstitute(
id: $id,
tenantId: $tenantId,
originalFilename: 'eleves.csv',
totalRows: 50,
detectedColumns: ['Nom', 'Prénom', 'Classe'],
detectedFormat: KnownImportFormat::PRONOTE,
status: ImportStatus::COMPLETED,
mapping: $mapping,
importedCount: 45,
errorCount: 5,
createdAt: $createdAt,
completedAt: $completedAt,
);
self::assertTrue($batch->id->equals($id));
self::assertTrue($batch->tenantId->equals($tenantId));
self::assertSame('eleves.csv', $batch->originalFilename);
self::assertSame(50, $batch->totalRows);
self::assertSame(ImportStatus::COMPLETED, $batch->status);
self::assertNotNull($batch->mapping);
self::assertSame(45, $batch->importedCount);
self::assertSame(5, $batch->errorCount);
self::assertEquals($createdAt, $batch->createdAt);
self::assertEquals($completedAt, $batch->completedAt);
self::assertEmpty($batch->pullDomainEvents());
}
private function createBatch(): StudentImportBatch
{
return StudentImportBatch::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
originalFilename: 'eleves.csv',
totalRows: 50,
detectedColumns: ['Nom', 'Prénom', 'Classe', 'Email'],
detectedFormat: KnownImportFormat::CUSTOM,
createdAt: new DateTimeImmutable('2026-02-24 10:00:00'),
);
}
private function createValidMapping(): ColumnMapping
{
return ColumnMapping::creer(
[
'Nom' => StudentImportField::LAST_NAME,
'Prénom' => StudentImportField::FIRST_NAME,
'Classe' => StudentImportField::CLASS_NAME,
],
KnownImportFormat::CUSTOM,
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Import;
use App\Administration\Domain\Model\Import\StudentImportField;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class StudentImportFieldTest extends TestCase
{
#[Test]
public function champsObligatoiresReturnsRequiredFields(): void
{
$required = StudentImportField::champsObligatoires();
self::assertCount(3, $required);
self::assertContains(StudentImportField::LAST_NAME, $required);
self::assertContains(StudentImportField::FIRST_NAME, $required);
self::assertContains(StudentImportField::CLASS_NAME, $required);
}
#[Test]
public function estObligatoireForRequiredFields(): void
{
self::assertTrue(StudentImportField::LAST_NAME->estObligatoire());
self::assertTrue(StudentImportField::FIRST_NAME->estObligatoire());
self::assertTrue(StudentImportField::CLASS_NAME->estObligatoire());
}
#[Test]
public function estObligatoireFalseForOptionalFields(): void
{
self::assertFalse(StudentImportField::EMAIL->estObligatoire());
self::assertFalse(StudentImportField::BIRTH_DATE->estObligatoire());
self::assertFalse(StudentImportField::GENDER->estObligatoire());
self::assertFalse(StudentImportField::STUDENT_NUMBER->estObligatoire());
}
#[Test]
public function labelReturnsReadableText(): void
{
self::assertSame('Nom', StudentImportField::LAST_NAME->label());
self::assertSame('Prénom', StudentImportField::FIRST_NAME->label());
self::assertSame('Classe', StudentImportField::CLASS_NAME->label());
self::assertSame('Email', StudentImportField::EMAIL->label());
}
}

View File

@@ -0,0 +1,3 @@
Nom,Prénom,Classe
Dupont,Jean,6ème A
Martin,Marie,6ème B
1 Nom Prénom Classe
2 Dupont Jean 6ème A
3 Martin Marie 6ème B

View File

@@ -0,0 +1,3 @@
NOM;PRENOM;CLASSE;DATE_NAISSANCE;SEXE
Dupont;Jean;6A;2014-03-15;M
Martin;Marie;6B;2014-07-22;F
1 NOM PRENOM CLASSE DATE_NAISSANCE SEXE
2 Dupont Jean 6A 2014-03-15 M
3 Martin Marie 6B 2014-07-22 F

View File

@@ -0,0 +1,28 @@
Élèves;Encouragement/Valorisation;Né(e) le;Sexe;Adresse E-mail;Entrée;Sortie;Classe de rattachement;Tuteur;Cnx Ele.;Cnx Resp.;Option 1;Option 2;Option 3;Régime
"BERTHE Alexandre";"";"07/07/2011";"Masculin";"alexandre.berthe@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"EXTERNE LIBRE"
"BILLAUD Amelia";"";"30/01/2011";"Féminin";"amelia.billaud@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"BILLET Julien";"";"22/04/2011";"Masculin";"julien.billet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"09/01/2019";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"EXTERNE LIBRE"
"BLANCHET Antoine";"";"11/10/2011";"Masculin";"antoine.blanchet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"BONNET Adeline";"";"10/12/2011";"Féminin";"adeline.bonnet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"CAZENAVE Valentin";"";"15/08/2010";"Masculin";"valentin.cazenave@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"CHABE Ilyes";"";"03/10/2011";"Masculin";"ilyes.chabe@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"CHOPIN Elisa";"";"24/02/2011";"Féminin";"elisa.chopin@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"EXTERNE LIBRE"
"DELAUNAY Alexandre";"";"16/09/2011";"Masculin";"alexandre.delaunay@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"DIOT Melanie";"";"20/12/2010";"Féminin";"melanie.diot@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"ESTEVE Martin";"";"09/07/2011";"Masculin";"martin.esteve@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"FERNANDEZ Juliette";"";"16/05/2011";"Féminin";"juliette.fernandez@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"GRANGE Sabrina";"";"16/01/2010";"Féminin";"sabrina.grange@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"EXTERNE LIBRE"
"HUGUET Clara";"";"11/01/2012";"Féminin";"clara.huguet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"EXTERNE LIBRE"
"IMBERT Vincent";"";"28/02/2012";"Masculin";"vincent.imbert@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"EXTERNE LIBRE"
"LAVIGNE Sandy";"";"09/01/2012";"Féminin";"sandy.lavigne@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"EXTERNE LIBRE"
"MATHIS Hugo";"";"22/04/2011";"Masculin";"hugo.mathis@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"MAYER Laura";"";"11/07/2011";"Féminin";"laura.mayer@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"MENAGER Pauline";"";"05/01/2012";"Féminin";"pauline.menager@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"MONTAGNE Clement";"";"10/01/2012";"Masculin";"clement.montagne@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"OLIVIER Jean-Philippe";"";"03/01/2012";"Masculin";"jean-philippe.olivier@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"PEREZ Alison";"";"09/07/2011";"Féminin";"alison.perez@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"RUIZ Delphine";"";"03/05/2011";"Féminin";"delphine.ruiz@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"EXTERNE LIBRE"
"SALOMON Alexandre";"";"14/05/2011";"Masculin";"alexandre.salomon@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"";"EXTERNE LIBRE"
"SCHMITT Romain";"";"22/08/2011";"Masculin";"romain.schmitt@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ESPAGNOL LV2";"";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
"SERRES Adeline";"";"07/12/2010";"Féminin";"adeline.serres@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"EXTERNE LIBRE"
"VALLET Alexandre";"";"23/03/2011";"Masculin";"alexandre.vallet@fournisseur.fr";01/07/2025;;"";"";"25/01/2019";"";"ANGLAIS LV1";"ALLEMAND LV2";"LATIN";"DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT"
1 Élèves Encouragement/Valorisation Né(e) le Sexe Adresse E-mail Entrée Sortie Classe de rattachement Tuteur Cnx Ele. Cnx Resp. Option 1 Option 2 Option 3 Régime
2 BERTHE Alexandre 07/07/2011 Masculin alexandre.berthe@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 EXTERNE LIBRE
3 BILLAUD Amelia 30/01/2011 Féminin amelia.billaud@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
4 BILLET Julien 22/04/2011 Masculin julien.billet@fournisseur.fr 01/07/2025 25/01/2019 09/01/2019 ANGLAIS LV1 ALLEMAND LV2 LATIN EXTERNE LIBRE
5 BLANCHET Antoine 11/10/2011 Masculin antoine.blanchet@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
6 BONNET Adeline 10/12/2011 Féminin adeline.bonnet@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 LATIN DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
7 CAZENAVE Valentin 15/08/2010 Masculin valentin.cazenave@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
8 CHABE Ilyes 03/10/2011 Masculin ilyes.chabe@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
9 CHOPIN Elisa 24/02/2011 Féminin elisa.chopin@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 EXTERNE LIBRE
10 DELAUNAY Alexandre 16/09/2011 Masculin alexandre.delaunay@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 LATIN DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
11 DIOT Melanie 20/12/2010 Féminin melanie.diot@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
12 ESTEVE Martin 09/07/2011 Masculin martin.esteve@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
13 FERNANDEZ Juliette 16/05/2011 Féminin juliette.fernandez@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
14 GRANGE Sabrina 16/01/2010 Féminin sabrina.grange@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 EXTERNE LIBRE
15 HUGUET Clara 11/01/2012 Féminin clara.huguet@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 LATIN EXTERNE LIBRE
16 IMBERT Vincent 28/02/2012 Masculin vincent.imbert@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 EXTERNE LIBRE
17 LAVIGNE Sandy 09/01/2012 Féminin sandy.lavigne@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 EXTERNE LIBRE
18 MATHIS Hugo 22/04/2011 Masculin hugo.mathis@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 LATIN DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
19 MAYER Laura 11/07/2011 Féminin laura.mayer@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
20 MENAGER Pauline 05/01/2012 Féminin pauline.menager@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
21 MONTAGNE Clement 10/01/2012 Masculin clement.montagne@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
22 OLIVIER Jean-Philippe 03/01/2012 Masculin jean-philippe.olivier@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 LATIN DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
23 PEREZ Alison 09/07/2011 Féminin alison.perez@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 LATIN DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
24 RUIZ Delphine 03/05/2011 Féminin delphine.ruiz@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 EXTERNE LIBRE
25 SALOMON Alexandre 14/05/2011 Masculin alexandre.salomon@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 EXTERNE LIBRE
26 SCHMITT Romain 22/08/2011 Masculin romain.schmitt@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ESPAGNOL LV2 DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT
27 SERRES Adeline 07/12/2010 Féminin adeline.serres@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 LATIN EXTERNE LIBRE
28 VALLET Alexandre 23/03/2011 Masculin alexandre.vallet@fournisseur.fr 01/07/2025 25/01/2019 ANGLAIS LV1 ALLEMAND LV2 LATIN DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT

View File

@@ -0,0 +1,4 @@
Nom;Prénom;Classe;Email
Dupont;Jean;6ème A;jean.dupont@email.com
Martin;Marie;6ème B;marie.martin@email.com
Bernard;Pierre;5ème A;
1 Nom Prénom Classe Email
2 Dupont Jean 6ème A jean.dupont@email.com
3 Martin Marie 6ème B marie.martin@email.com
4 Bernard Pierre 5ème A