Lorsqu'un admin créait un élève de moins de 15 ans avec une date de naissance, le compte ne pouvait pas être activé car le consentement parental RGPD n'avait jamais été enregistré — aucun mécanisme ne le permettait dans le parcours admin. Ajout d'une case « Consentement parental obtenu » dans le formulaire de création d'élève, affichée conditionnellement quand la date de naissance indique un âge < 15 ans. L'admin confirme que l'établissement a recueilli le consentement, qui est alors enregistré côté backend lors de la création du compte.
305 lines
11 KiB
PHP
305 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Application\Command\CreateStudent;
|
|
|
|
use App\Administration\Application\Command\CreateStudent\CreateStudentCommand;
|
|
use App\Administration\Application\Command\CreateStudent\CreateStudentHandler;
|
|
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
|
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
|
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
|
use App\Administration\Domain\Model\User\Email;
|
|
use App\Administration\Domain\Model\User\Role;
|
|
use App\Administration\Domain\Model\User\StatutCompte;
|
|
use App\Administration\Domain\Model\User\User;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Domain\Repository\UserRepository;
|
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
|
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use Doctrine\DBAL\Connection;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use RuntimeException;
|
|
|
|
final class CreateStudentHandlerTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
|
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
|
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
|
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440050';
|
|
|
|
private InMemoryUserRepository $userRepository;
|
|
private InMemoryClassAssignmentRepository $classAssignmentRepository;
|
|
private InMemoryClassRepository $classRepository;
|
|
private Connection $connection;
|
|
private Clock $clock;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->userRepository = new InMemoryUserRepository();
|
|
$this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
|
|
$this->classRepository = new InMemoryClassRepository();
|
|
$this->connection = $this->createMock(Connection::class);
|
|
$this->connection->method('beginTransaction');
|
|
$this->connection->method('commit');
|
|
$this->clock = new class implements Clock {
|
|
public function now(): DateTimeImmutable
|
|
{
|
|
return new DateTimeImmutable('2026-02-21 10:00:00');
|
|
}
|
|
};
|
|
|
|
$this->seedTestData();
|
|
}
|
|
|
|
#[Test]
|
|
public function itCreatesStudentWithEmail(): void
|
|
{
|
|
$handler = $this->createHandler();
|
|
$command = $this->createCommand(email: 'eleve@example.com');
|
|
|
|
$user = $handler($command);
|
|
|
|
self::assertSame('Marie', $user->firstName);
|
|
self::assertSame('Dupont', $user->lastName);
|
|
self::assertSame(StatutCompte::EN_ATTENTE, $user->statut);
|
|
self::assertSame('eleve@example.com', (string) $user->email);
|
|
self::assertTrue($user->aLeRole(Role::ELEVE));
|
|
}
|
|
|
|
#[Test]
|
|
public function itCreatesStudentWithoutEmail(): void
|
|
{
|
|
$handler = $this->createHandler();
|
|
$command = $this->createCommand(email: null);
|
|
|
|
$user = $handler($command);
|
|
|
|
self::assertSame('Marie', $user->firstName);
|
|
self::assertSame('Dupont', $user->lastName);
|
|
self::assertSame(StatutCompte::INSCRIT, $user->statut);
|
|
self::assertNull($user->email);
|
|
self::assertTrue($user->aLeRole(Role::ELEVE));
|
|
}
|
|
|
|
#[Test]
|
|
public function itAssignsStudentToClass(): void
|
|
{
|
|
$handler = $this->createHandler();
|
|
$command = $this->createCommand();
|
|
|
|
$user = $handler($command);
|
|
|
|
$assignment = $this->classAssignmentRepository->findByStudent(
|
|
$user->id,
|
|
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
|
TenantId::fromString(self::TENANT_ID),
|
|
);
|
|
|
|
self::assertNotNull($assignment);
|
|
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
|
}
|
|
|
|
#[Test]
|
|
public function itSetsStudentNumber(): void
|
|
{
|
|
$handler = $this->createHandler();
|
|
$command = $this->createCommand(studentNumber: '12345678901');
|
|
|
|
$user = $handler($command);
|
|
|
|
self::assertSame('12345678901', $user->studentNumber);
|
|
}
|
|
|
|
#[Test]
|
|
public function itSetsDateNaissance(): void
|
|
{
|
|
$handler = $this->createHandler();
|
|
$command = $this->createCommand(dateNaissance: '2015-06-15');
|
|
|
|
$user = $handler($command);
|
|
|
|
self::assertNotNull($user->dateNaissance);
|
|
self::assertSame('2015-06-15', $user->dateNaissance->format('Y-m-d'));
|
|
}
|
|
|
|
#[Test]
|
|
public function itThrowsWhenClassDoesNotExist(): void
|
|
{
|
|
$handler = $this->createHandler();
|
|
$command = $this->createCommand(classId: '550e8400-e29b-41d4-a716-446655440099');
|
|
|
|
$this->expectException(ClasseNotFoundException::class);
|
|
$handler($command);
|
|
}
|
|
|
|
#[Test]
|
|
public function itThrowsWhenEmailAlreadyUsedInTenant(): void
|
|
{
|
|
// Pre-populate with a user having the same email
|
|
$existing = User::reconstitute(
|
|
id: UserId::fromString('550e8400-e29b-41d4-a716-446655440060'),
|
|
email: new Email('existing@example.com'),
|
|
roles: [Role::ELEVE],
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: 'École Test',
|
|
statut: StatutCompte::EN_ATTENTE,
|
|
dateNaissance: null,
|
|
createdAt: new DateTimeImmutable('2026-01-15'),
|
|
hashedPassword: null,
|
|
activatedAt: null,
|
|
consentementParental: null,
|
|
);
|
|
$this->userRepository->save($existing);
|
|
|
|
$handler = $this->createHandler();
|
|
$command = $this->createCommand(email: 'existing@example.com');
|
|
|
|
$this->expectException(EmailDejaUtiliseeException::class);
|
|
$handler($command);
|
|
}
|
|
|
|
#[Test]
|
|
public function itRollsBackTransactionOnFailure(): void
|
|
{
|
|
$connection = $this->createMock(Connection::class);
|
|
$connection->expects(self::once())->method('beginTransaction');
|
|
$connection->expects(self::once())->method('rollBack');
|
|
$connection->expects(self::never())->method('commit');
|
|
|
|
// Use a mock UserRepository that throws during save (inside the transaction)
|
|
$failingUserRepo = $this->createMock(UserRepository::class);
|
|
$failingUserRepo->method('findByEmail')->willReturn(null);
|
|
$failingUserRepo->method('save')->willThrowException(new RuntimeException('DB write failed'));
|
|
|
|
$handler = new CreateStudentHandler(
|
|
$failingUserRepo,
|
|
$this->classAssignmentRepository,
|
|
$this->classRepository,
|
|
$connection,
|
|
$this->clock,
|
|
new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock),
|
|
);
|
|
|
|
$command = $this->createCommand();
|
|
|
|
$this->expectException(RuntimeException::class);
|
|
$handler($command);
|
|
}
|
|
|
|
#[Test]
|
|
public function itThrowsWhenClassBelongsToAnotherTenant(): void
|
|
{
|
|
$otherTenantClassId = '550e8400-e29b-41d4-a716-446655440080';
|
|
$otherClass = SchoolClass::reconstitute(
|
|
id: ClassId::fromString($otherTenantClassId),
|
|
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
|
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
|
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
|
name: new ClassName('Autre tenant'),
|
|
level: SchoolLevel::SIXIEME,
|
|
capacity: 30,
|
|
status: ClassStatus::ACTIVE,
|
|
description: null,
|
|
createdAt: new DateTimeImmutable('2026-01-15'),
|
|
updatedAt: new DateTimeImmutable('2026-01-15'),
|
|
deletedAt: null,
|
|
);
|
|
$this->classRepository->save($otherClass);
|
|
|
|
$handler = $this->createHandler();
|
|
$command = $this->createCommand(classId: $otherTenantClassId);
|
|
|
|
$this->expectException(ClasseNotFoundException::class);
|
|
$handler($command);
|
|
}
|
|
|
|
#[Test]
|
|
public function itThrowsWhenClassIsArchived(): void
|
|
{
|
|
$archivedClassId = '550e8400-e29b-41d4-a716-446655440081';
|
|
$archivedClass = SchoolClass::reconstitute(
|
|
id: ClassId::fromString($archivedClassId),
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
|
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
|
name: new ClassName('Archivée'),
|
|
level: SchoolLevel::SIXIEME,
|
|
capacity: 30,
|
|
status: ClassStatus::ARCHIVED,
|
|
description: null,
|
|
createdAt: new DateTimeImmutable('2026-01-15'),
|
|
updatedAt: new DateTimeImmutable('2026-01-15'),
|
|
deletedAt: null,
|
|
);
|
|
$this->classRepository->save($archivedClass);
|
|
|
|
$handler = $this->createHandler();
|
|
$command = $this->createCommand(classId: $archivedClassId);
|
|
|
|
$this->expectException(ClasseNotFoundException::class);
|
|
$handler($command);
|
|
}
|
|
|
|
private function seedTestData(): void
|
|
{
|
|
$class = SchoolClass::reconstitute(
|
|
id: ClassId::fromString(self::CLASS_ID),
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
|
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
|
name: new ClassName('6ème A'),
|
|
level: SchoolLevel::SIXIEME,
|
|
capacity: 30,
|
|
status: ClassStatus::ACTIVE,
|
|
description: null,
|
|
createdAt: new DateTimeImmutable('2026-01-15'),
|
|
updatedAt: new DateTimeImmutable('2026-01-15'),
|
|
deletedAt: null,
|
|
);
|
|
$this->classRepository->save($class);
|
|
}
|
|
|
|
private function createHandler(): CreateStudentHandler
|
|
{
|
|
return new CreateStudentHandler(
|
|
$this->userRepository,
|
|
$this->classAssignmentRepository,
|
|
$this->classRepository,
|
|
$this->connection,
|
|
$this->clock,
|
|
new \App\Administration\Domain\Policy\ConsentementParentalPolicy($this->clock),
|
|
);
|
|
}
|
|
|
|
private function createCommand(
|
|
?string $email = 'eleve@example.com',
|
|
?string $classId = null,
|
|
?string $dateNaissance = null,
|
|
?string $studentNumber = null,
|
|
): CreateStudentCommand {
|
|
return new CreateStudentCommand(
|
|
tenantId: self::TENANT_ID,
|
|
schoolName: 'École Test',
|
|
firstName: 'Marie',
|
|
lastName: 'Dupont',
|
|
classId: $classId ?? self::CLASS_ID,
|
|
academicYearId: self::ACADEMIC_YEAR_ID,
|
|
email: $email,
|
|
dateNaissance: $dateNaissance,
|
|
studentNumber: $studentNumber,
|
|
);
|
|
}
|
|
}
|