feat: Permettre la génération et l'envoi de codes d'invitation aux parents
Les administrateurs ont besoin d'un moyen simple pour inviter les parents à rejoindre la plateforme. Cette fonctionnalité permet de générer des codes d'invitation uniques (8 caractères alphanumériques) avec une validité de 48h, de les envoyer par email, et de les activer via une page publique dédiée qui crée automatiquement le compte parent. L'interface d'administration offre l'envoi unitaire et en masse, le renvoi, le filtrage par statut, ainsi que la visualisation de l'état de chaque invitation (en attente, activée, expirée).
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Application\Service\Import\CsvParser;
|
||||
use App\Administration\Domain\Model\Import\ParentInvitationImportField;
|
||||
|
||||
use function mb_strtolower;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use function str_contains;
|
||||
|
||||
/**
|
||||
* Test d'intégration de la chaîne d'import invitations parents avec de vrais fichiers CSV.
|
||||
*
|
||||
* Parse → Mapping suggestion → Validation des champs
|
||||
*/
|
||||
final class ParentImportIntegrationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function parseSimpleParentCsv(): void
|
||||
{
|
||||
$parser = new CsvParser();
|
||||
$parseResult = $parser->parse($this->fixture('parents_simple.csv'));
|
||||
|
||||
self::assertSame(['Nom élève', 'Email parent 1', 'Email parent 2'], $parseResult->columns);
|
||||
self::assertSame(3, $parseResult->totalRows());
|
||||
self::assertSame('Dupont Alice', $parseResult->rows[0]['Nom élève']);
|
||||
self::assertSame('alice.parent1@email.com', $parseResult->rows[0]['Email parent 1']);
|
||||
self::assertSame('alice.parent2@email.com', $parseResult->rows[0]['Email parent 2']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseCommaSeparatedParentCsv(): void
|
||||
{
|
||||
$parser = new CsvParser();
|
||||
$parseResult = $parser->parse($this->fixture('parents_comma.csv'));
|
||||
|
||||
self::assertSame(['Nom élève', 'Email parent 1', 'Email parent 2'], $parseResult->columns);
|
||||
self::assertSame(2, $parseResult->totalRows());
|
||||
self::assertSame('Dupont Alice', $parseResult->rows[0]['Nom élève']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestMappingForParentColumns(): void
|
||||
{
|
||||
$parser = new CsvParser();
|
||||
$parseResult = $parser->parse($this->fixture('parents_simple.csv'));
|
||||
|
||||
$mapping = $this->suggestMapping($parseResult->columns);
|
||||
|
||||
self::assertSame(ParentInvitationImportField::STUDENT_NAME->value, $mapping['Nom élève']);
|
||||
self::assertSame(ParentInvitationImportField::EMAIL_1->value, $mapping['Email parent 1']);
|
||||
self::assertSame(ParentInvitationImportField::EMAIL_2->value, $mapping['Email parent 2']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function completParentCsvHasExpectedStructure(): void
|
||||
{
|
||||
$parser = new CsvParser();
|
||||
$parseResult = $parser->parse($this->fixture('parents_complet.csv'));
|
||||
|
||||
self::assertSame(8, $parseResult->totalRows());
|
||||
|
||||
// Ligne 3 : Bernard Pierre — email1 manquant
|
||||
self::assertSame('Bernard Pierre', $parseResult->rows[2]['Nom élève']);
|
||||
self::assertSame('', $parseResult->rows[2]['Email parent 1']);
|
||||
|
||||
// Ligne 4 : nom élève manquant
|
||||
self::assertSame('', $parseResult->rows[3]['Nom élève']);
|
||||
self::assertSame('orphelin@email.com', $parseResult->rows[3]['Email parent 1']);
|
||||
|
||||
// Ligne 5 : email invalide
|
||||
self::assertSame('invalide-email', $parseResult->rows[4]['Email parent 1']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function requiredFieldsAreCorrect(): void
|
||||
{
|
||||
$required = ParentInvitationImportField::champsObligatoires();
|
||||
|
||||
self::assertCount(2, $required);
|
||||
self::assertContains(ParentInvitationImportField::STUDENT_NAME, $required);
|
||||
self::assertContains(ParentInvitationImportField::EMAIL_1, $required);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function email2IsOptional(): void
|
||||
{
|
||||
self::assertFalse(ParentInvitationImportField::EMAIL_2->estObligatoire());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduit la logique de suggestMapping du controller pour pouvoir la tester.
|
||||
*
|
||||
* @param list<string> $columns
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function suggestMapping(array $columns): array
|
||||
{
|
||||
$mapping = [];
|
||||
$email1Found = false;
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$lower = mb_strtolower($column);
|
||||
|
||||
if ($this->isStudentNameColumn($lower) && !isset($mapping[$column])) {
|
||||
$mapping[$column] = ParentInvitationImportField::STUDENT_NAME->value;
|
||||
} elseif (str_contains($lower, 'email') || str_contains($lower, 'mail') || str_contains($lower, 'courriel')) {
|
||||
if (str_contains($lower, '2') || str_contains($lower, 'parent 2')) {
|
||||
$mapping[$column] = ParentInvitationImportField::EMAIL_2->value;
|
||||
} elseif (!$email1Found) {
|
||||
$mapping[$column] = ParentInvitationImportField::EMAIL_1->value;
|
||||
$email1Found = true;
|
||||
} else {
|
||||
$mapping[$column] = ParentInvitationImportField::EMAIL_2->value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
private function isStudentNameColumn(string $lower): bool
|
||||
{
|
||||
return str_contains($lower, 'élève')
|
||||
|| str_contains($lower, 'eleve')
|
||||
|| str_contains($lower, 'étudiant')
|
||||
|| str_contains($lower, 'etudiant')
|
||||
|| str_contains($lower, 'student')
|
||||
|| $lower === 'nom';
|
||||
}
|
||||
|
||||
private function fixture(string $filename): string
|
||||
{
|
||||
return __DIR__ . '/../../../../../fixtures/import/' . $filename;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Application\Service\InvitationCodeGenerator;
|
||||
use App\Administration\Domain\Model\Invitation\InvitationCode;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use function strlen;
|
||||
|
||||
final class InvitationCodeGeneratorTest extends TestCase
|
||||
{
|
||||
private InvitationCodeGenerator $generator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->generator = new InvitationCodeGenerator();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function generateReturnsInvitationCode(): void
|
||||
{
|
||||
$code = $this->generator->generate();
|
||||
|
||||
self::assertInstanceOf(InvitationCode::class, $code);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function generateReturns32CharacterHexCode(): void
|
||||
{
|
||||
$code = $this->generator->generate();
|
||||
|
||||
self::assertSame(32, strlen($code->value));
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $code->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function generateProducesUniqueCodesEachTime(): void
|
||||
{
|
||||
$code1 = $this->generator->generate();
|
||||
$code2 = $this->generator->generate();
|
||||
|
||||
self::assertFalse($code1->equals($code2));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user