feat(demo): add tenant demo data generator
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Add a relaunchable demo seed flow so a tenant can be populated quickly on a VPS or demo environment without manual setup.
This commit is contained in:
2026-03-10 22:44:39 +01:00
parent 8a3262faf9
commit ee62beea8c
8 changed files with 1810 additions and 2 deletions

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Console;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Infrastructure\Service\DemoDataGenerationResult;
use App\Administration\Infrastructure\Service\DemoDataGenerator;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function array_map;
use function count;
use function getenv;
use function implode;
use Override;
use function sprintf;
use function strtolower;
use function strtoupper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Process;
use Throwable;
use function trim;
#[AsCommand(
name: 'app:dev:generate-demo-data',
description: 'Génère un jeu de données complet pour un tenant de démonstration',
)]
final class GenerateDemoDataCommand extends Command
{
public function __construct(
private readonly TenantRegistry $tenantRegistry,
private readonly DemoDataGenerator $demoDataGenerator,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
#[Override]
protected function configure(): void
{
$this
->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Sous-domaine du tenant cible')
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Mot de passe partagé pour tous les comptes de démo', 'DemoPassword123!')
->addOption('school', null, InputOption::VALUE_OPTIONAL, 'Nom de létablissement affiché sur les comptes générés')
->addOption('zone', null, InputOption::VALUE_OPTIONAL, 'Zone scolaire (A, B ou C)', 'B')
->addOption('period-type', null, InputOption::VALUE_OPTIONAL, 'Découpage des périodes (trimester ou semester)', PeriodType::TRIMESTER->value)
->addOption('internal-run', null, InputOption::VALUE_NONE, 'Option interne pour exécuter la génération dans la base tenant');
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var string|null $tenantOption */
$tenantOption = $input->getOption('tenant');
/** @var string $password */
$password = $input->getOption('password');
/** @var string|null $schoolOption */
$schoolOption = $input->getOption('school');
/** @var string $zoneOption */
$zoneOption = $input->getOption('zone');
/** @var string $periodTypeOption */
$periodTypeOption = $input->getOption('period-type');
$internalRun = $input->getOption('internal-run');
$tenantConfig = $this->resolveTenantConfig($tenantOption, $io);
if ($tenantConfig === null) {
return Command::FAILURE;
}
$zone = SchoolZone::tryFrom(strtoupper(trim($zoneOption)));
if ($zone === null) {
$io->error('Zone invalide. Valeurs attendues: A, B, C.');
return Command::FAILURE;
}
$periodType = PeriodType::tryFrom(strtolower(trim($periodTypeOption)));
if ($periodType === null) {
$io->error('Type de période invalide. Valeurs attendues: trimester, semester.');
return Command::FAILURE;
}
$schoolName = trim((string) $schoolOption);
if ($schoolName === '') {
$schoolName = $this->demoDataGenerator->defaultSchoolNameForSubdomain($tenantConfig->subdomain);
}
if ($internalRun) {
return $this->runInTenantProcess($tenantConfig, $password, $schoolName, $zone, $periodType, $io);
}
return $this->relaunchAgainstTenantDatabase($tenantConfig, $password, $schoolName, $zone, $periodType, $io);
}
private function relaunchAgainstTenantDatabase(
TenantConfig $tenantConfig,
string $password,
string $schoolName,
SchoolZone $zone,
PeriodType $periodType,
SymfonyStyle $io,
): int {
$io->section(sprintf(
'Génération du jeu de démo pour le tenant "%s" sur sa base dédiée',
$tenantConfig->subdomain,
));
$process = new Process(
command: [
'php',
'bin/console',
'app:dev:generate-demo-data',
'--tenant=' . $tenantConfig->subdomain,
'--password=' . $password,
'--school=' . $schoolName,
'--zone=' . $zone->value,
'--period-type=' . $periodType->value,
'--internal-run',
],
cwd: $this->projectDir,
env: [
...getenv(),
'DATABASE_URL' => $tenantConfig->databaseUrl,
],
timeout: 300,
);
$process->run(static function (string $type, string $buffer) use ($io): void {
$io->write($buffer);
});
if ($process->isSuccessful()) {
return Command::SUCCESS;
}
$io->error(sprintf(
'La génération a échoué pour le tenant "%s".',
$tenantConfig->subdomain,
));
return Command::FAILURE;
}
private function runInTenantProcess(
TenantConfig $tenantConfig,
string $password,
string $schoolName,
SchoolZone $zone,
PeriodType $periodType,
SymfonyStyle $io,
): int {
try {
$result = $this->demoDataGenerator->generate(
tenantConfig: $tenantConfig,
password: $password,
schoolName: $schoolName,
zone: $zone,
periodType: $periodType,
);
} catch (Throwable $e) {
$io->error([
sprintf('Impossible de générer les données de démo pour "%s".', $tenantConfig->subdomain),
$e->getMessage(),
]);
return Command::FAILURE;
}
$this->renderResult($result, $io);
return Command::SUCCESS;
}
private function renderResult(DemoDataGenerationResult $result, SymfonyStyle $io): void
{
$io->success(sprintf(
'Jeu de démo prêt pour le tenant "%s" (%s).',
$result->tenantSubdomain,
$result->academicYearLabel,
));
$io->table(
['Élément', 'Résultat'],
[
['Établissement', $result->schoolName],
['Utilisateurs créés', (string) $result->createdUsers],
['Matières créées', (string) $result->createdSubjects],
['Classes créées', (string) $result->createdClasses],
['Affectations élèves', (string) $result->createdClassAssignments],
['Affectations enseignants', (string) $result->createdTeacherAssignments],
['Liens parent-enfant', (string) $result->createdGuardianLinks],
['Créneaux demploi du temps', (string) $result->createdScheduleSlots],
['Périodes créées', $result->periodConfigurationCreated ? 'oui' : 'non'],
['Calendrier créé', $result->schoolCalendarCreated ? 'oui' : 'non'],
['Mode de notation créé', $result->gradingConfigurationCreated ? 'oui' : 'non'],
],
);
$io->writeln(sprintf('Mot de passe commun: <info>%s</info>', $result->sharedPassword));
$io->table(
['Rôle', 'Nom', 'Email'],
array_map(
static fn (array $account): array => [
$account['role'],
$account['name'],
$account['email'],
],
$result->accounts,
),
);
if ($result->warnings !== []) {
$io->warning($result->warnings);
}
}
private function resolveTenantConfig(?string $tenantOption, SymfonyStyle $io): ?TenantConfig
{
$tenant = trim((string) $tenantOption);
if ($tenant !== '') {
try {
return $this->tenantRegistry->getBySubdomain($tenant);
} catch (TenantNotFoundException) {
$io->error(sprintf(
'Tenant "%s" introuvable. Disponibles: %s',
$tenant,
implode(', ', $this->availableSubdomains()),
));
return null;
}
}
$configs = $this->tenantRegistry->getAllConfigs();
if (count($configs) === 1) {
return $configs[0];
}
$io->error(sprintf(
'Plusieurs tenants sont configurés. Précise --tenant parmi: %s',
implode(', ', $this->availableSubdomains()),
));
return null;
}
/**
* @return list<string>
*/
private function availableSubdomains(): array
{
return array_values(array_map(
static fn (TenantConfig $config): string => $config->subdomain,
$this->tenantRegistry->getAllConfigs(),
));
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
final class DemoDataGenerationResult
{
public int $createdUsers = 0;
public int $createdSubjects = 0;
public int $createdClasses = 0;
public int $createdClassAssignments = 0;
public int $createdTeacherAssignments = 0;
public int $createdGuardianLinks = 0;
public int $createdScheduleSlots = 0;
public bool $periodConfigurationCreated = false;
public bool $schoolCalendarCreated = false;
public bool $gradingConfigurationCreated = false;
/** @var list<array{role: string, name: string, email: string}> */
public array $accounts = [];
/** @var list<string> */
public array $warnings = [];
public function __construct(
public readonly string $tenantSubdomain,
public readonly string $schoolName,
public readonly string $academicYearLabel,
public readonly string $sharedPassword,
) {
}
public function addAccount(string $role, string $name, string $email): void
{
$this->accounts[] = [
'role' => $role,
'name' => $name,
'email' => $email,
];
}
public function addWarning(string $warning): void
{
$this->warnings[] = $warning;
}
}

View File

@@ -0,0 +1,957 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use App\Administration\Application\Port\OfficialCalendarProvider;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfiguration;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
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\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectColor;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
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\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\GradingConfigurationRepository;
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use DateTimeImmutable;
use RuntimeException;
use function sprintf;
use function str_replace;
use function strtolower;
use function trim;
use function ucwords;
final readonly class DemoDataGenerator
{
public function __construct(
private UserRepository $userRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private ClassAssignmentRepository $classAssignmentRepository,
private TeacherAssignmentRepository $teacherAssignmentRepository,
private StudentGuardianRepository $studentGuardianRepository,
private ScheduleSlotRepository $scheduleSlotRepository,
private PeriodConfigurationRepository $periodConfigurationRepository,
private \App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository $schoolCalendarRepository,
private GradingConfigurationRepository $gradingConfigurationRepository,
private PasswordHasher $passwordHasher,
private Clock $clock,
private TenantContext $tenantContext,
private CurrentAcademicYearResolver $currentAcademicYearResolver,
private SchoolIdResolver $schoolIdResolver,
private OfficialCalendarProvider $officialCalendarProvider,
) {
}
public function generate(
TenantConfig $tenantConfig,
string $password,
string $schoolName,
SchoolZone $zone,
PeriodType $periodType,
): DemoDataGenerationResult {
$this->tenantContext->setCurrentTenant($tenantConfig);
try {
$tenantId = $tenantConfig->tenantId;
$academicYearId = $this->resolveAcademicYearId();
$academicYearStartYear = $this->resolveAcademicYearStartYear();
$academicYearLabel = sprintf('%d-%d', $academicYearStartYear, $academicYearStartYear + 1);
$schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant((string) $tenantId));
$now = $this->clock->now();
$hashedPassword = $this->passwordHasher->hash($password);
$result = new DemoDataGenerationResult(
tenantSubdomain: $tenantConfig->subdomain,
schoolName: $schoolName,
academicYearLabel: $academicYearLabel,
sharedPassword: $password,
);
$this->ensurePeriodConfiguration($tenantId, $academicYearId, $academicYearStartYear, $periodType, $result);
$this->ensureSchoolCalendar($tenantId, $academicYearId, $academicYearLabel, $zone, $now, $result);
$this->ensureGradingConfiguration($tenantId, $schoolId, $academicYearId, $now, $result);
$users = $this->seedUsers($tenantConfig, $schoolName, $hashedPassword, $now, $result);
$subjects = $this->seedSubjects($tenantId, $schoolId, $now, $result);
$classes = $this->seedClasses($tenantId, $schoolId, $academicYearId, $now, $result);
$this->seedClassAssignments($tenantId, $academicYearId, $users, $classes, $now, $result);
$this->seedTeacherAssignments($tenantId, $academicYearId, $users, $classes, $subjects, $now, $result);
$this->seedStudentGuardianLinks($tenantId, $users, $now, $result);
$this->seedScheduleSlots($tenantId, $classes, $subjects, $users, $academicYearStartYear, $now, $result);
return $result;
} finally {
$this->tenantContext->clear();
}
}
private function ensurePeriodConfiguration(
TenantId $tenantId,
AcademicYearId $academicYearId,
int $academicYearStartYear,
PeriodType $periodType,
DemoDataGenerationResult $result,
): void {
if ($this->periodConfigurationRepository->findByAcademicYear($tenantId, $academicYearId) !== null) {
return;
}
$configuration = DefaultPeriods::forType($periodType, $academicYearStartYear);
$this->periodConfigurationRepository->save($tenantId, $academicYearId, $configuration);
$result->periodConfigurationCreated = true;
}
private function ensureSchoolCalendar(
TenantId $tenantId,
AcademicYearId $academicYearId,
string $academicYearLabel,
SchoolZone $zone,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
): void {
if ($this->schoolCalendarRepository->findByTenantAndYear($tenantId, $academicYearId) !== null) {
return;
}
$calendar = SchoolCalendar::initialiser($tenantId, $academicYearId);
$calendar->configurer(
$zone,
$this->officialCalendarProvider->toutesEntreesOfficielles($zone, $academicYearLabel),
$now,
);
$this->schoolCalendarRepository->save($calendar);
$result->schoolCalendarCreated = true;
}
private function ensureGradingConfiguration(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
): void {
if ($this->gradingConfigurationRepository->findBySchoolAndYear($tenantId, $schoolId, $academicYearId) !== null) {
return;
}
$configuration = SchoolGradingConfiguration::configurer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
mode: SchoolGradingConfiguration::DEFAULT_MODE,
hasExistingGrades: false,
configuredAt: $now,
);
$this->gradingConfigurationRepository->save($configuration);
$result->gradingConfigurationCreated = true;
}
/**
* @return array<string, User>
*/
private function seedUsers(
TenantConfig $tenantConfig,
string $schoolName,
string $hashedPassword,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
): array {
$users = [];
foreach ($this->staffBlueprints() as $blueprint) {
$users[$blueprint['key']] = $this->ensureActiveUser(
tenantConfig: $tenantConfig,
schoolName: $schoolName,
hashedPassword: $hashedPassword,
role: $blueprint['role'],
emailSlug: $blueprint['emailSlug'],
firstName: $blueprint['firstName'],
lastName: $blueprint['lastName'],
now: $now,
result: $result,
);
}
foreach ($this->teacherBlueprints() as $blueprint) {
$users[$blueprint['key']] = $this->ensureActiveUser(
tenantConfig: $tenantConfig,
schoolName: $schoolName,
hashedPassword: $hashedPassword,
role: Role::PROF,
emailSlug: $blueprint['emailSlug'],
firstName: $blueprint['firstName'],
lastName: $blueprint['lastName'],
now: $now,
result: $result,
);
}
foreach ($this->parentBlueprints() as $blueprint) {
$users[$blueprint['key']] = $this->ensureActiveUser(
tenantConfig: $tenantConfig,
schoolName: $schoolName,
hashedPassword: $hashedPassword,
role: Role::PARENT,
emailSlug: $blueprint['emailSlug'],
firstName: $blueprint['firstName'],
lastName: $blueprint['lastName'],
now: $now,
result: $result,
);
}
foreach ($this->studentBlueprints() as $blueprint) {
$users[$blueprint['key']] = $this->ensureActiveUser(
tenantConfig: $tenantConfig,
schoolName: $schoolName,
hashedPassword: $hashedPassword,
role: Role::ELEVE,
emailSlug: $blueprint['emailSlug'],
firstName: $blueprint['firstName'],
lastName: $blueprint['lastName'],
now: $now,
result: $result,
dateOfBirth: $blueprint['dateOfBirth'],
studentNumber: $blueprint['studentNumber'],
);
}
return $users;
}
/**
* @return array<string, Subject>
*/
private function seedSubjects(
TenantId $tenantId,
SchoolId $schoolId,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
): array {
$subjects = [];
foreach ($this->subjectBlueprints() as $blueprint) {
$code = new SubjectCode($blueprint['code']);
$subject = $this->subjectRepository->findByCode($code, $tenantId, $schoolId);
if ($subject === null) {
$subject = Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName($blueprint['name']),
code: $code,
color: new SubjectColor($blueprint['color']),
createdAt: $now,
);
$subject->decrire($blueprint['description'], $now);
$this->subjectRepository->save($subject);
++$result->createdSubjects;
}
$subjects[$blueprint['code']] = $subject;
}
return $subjects;
}
/**
* @return array<string, SchoolClass>
*/
private function seedClasses(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
): array {
$classes = [];
foreach ($this->classBlueprints() as $blueprint) {
$name = new ClassName($blueprint['name']);
$class = $this->classRepository->findByName($name, $tenantId, $academicYearId);
if ($class === null) {
$class = SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: $name,
level: $blueprint['level'],
capacity: $blueprint['capacity'],
createdAt: $now,
);
$class->decrire($blueprint['description'], $now);
$this->classRepository->save($class);
++$result->createdClasses;
}
$classes[$blueprint['name']] = $class;
}
return $classes;
}
/**
* @param array<string, User> $users
* @param array<string, SchoolClass> $classes
*/
private function seedClassAssignments(
TenantId $tenantId,
AcademicYearId $academicYearId,
array $users,
array $classes,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
): void {
foreach ($this->studentBlueprints() as $blueprint) {
$student = $users[$blueprint['key']];
$class = $classes[$blueprint['className']];
$assignment = $this->classAssignmentRepository->findByStudent($student->id, $academicYearId, $tenantId);
if ($assignment === null) {
$assignment = ClassAssignment::affecter(
tenantId: $tenantId,
studentId: $student->id,
classId: $class->id,
academicYearId: $academicYearId,
assignedAt: $now,
);
$this->classAssignmentRepository->save($assignment);
++$result->createdClassAssignments;
continue;
}
if (!$assignment->classId->equals($class->id)) {
$assignment->changerClasse($class->id, $now);
$this->classAssignmentRepository->save($assignment);
}
}
}
/**
* @param array<string, User> $users
* @param array<string, SchoolClass> $classes
* @param array<string, Subject> $subjects
*/
private function seedTeacherAssignments(
TenantId $tenantId,
AcademicYearId $academicYearId,
array $users,
array $classes,
array $subjects,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
): void {
$teacherKeysBySubject = $this->teacherKeysBySubject();
foreach ($classes as $className => $class) {
foreach ($subjects as $subjectCode => $subject) {
$teacher = $users[$teacherKeysBySubject[$subjectCode]];
$assignment = $this->teacherAssignmentRepository->findByTeacherClassSubject(
teacherId: $teacher->id,
classId: $class->id,
subjectId: $subject->id,
academicYearId: $academicYearId,
tenantId: $tenantId,
);
if ($assignment !== null) {
continue;
}
$removedAssignment = $this->teacherAssignmentRepository->findRemovedByTeacherClassSubject(
teacherId: $teacher->id,
classId: $class->id,
subjectId: $subject->id,
academicYearId: $academicYearId,
tenantId: $tenantId,
);
if ($removedAssignment !== null) {
$removedAssignment->reactiver($now);
$this->teacherAssignmentRepository->save($removedAssignment);
continue;
}
$createdAssignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacher->id,
classId: $class->id,
subjectId: $subject->id,
academicYearId: $academicYearId,
createdAt: $now,
);
$this->teacherAssignmentRepository->save($createdAssignment);
++$result->createdTeacherAssignments;
}
}
}
/**
* @param array<string, User> $users
*/
private function seedStudentGuardianLinks(
TenantId $tenantId,
array $users,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
): void {
$createdBy = $users['admin']->id;
foreach ($this->guardianLinkBlueprints() as $blueprint) {
$student = $users[$blueprint['studentKey']];
$guardian = $users[$blueprint['guardianKey']];
$existingLink = $this->studentGuardianRepository->findByStudentAndGuardian(
studentId: $student->id,
guardianId: $guardian->id,
tenantId: $tenantId,
);
if ($existingLink !== null) {
continue;
}
$link = StudentGuardian::lier(
studentId: $student->id,
guardianId: $guardian->id,
relationshipType: $blueprint['relationshipType'],
tenantId: $tenantId,
createdAt: $now,
createdBy: $createdBy,
);
$this->studentGuardianRepository->save($link);
++$result->createdGuardianLinks;
}
}
/**
* @param array<string, SchoolClass> $classes
* @param array<string, Subject> $subjects
* @param array<string, User> $users
*/
private function seedScheduleSlots(
TenantId $tenantId,
array $classes,
array $subjects,
array $users,
int $academicYearStartYear,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
): void {
$teacherKeysBySubject = $this->teacherKeysBySubject();
$recurrenceStart = new DateTimeImmutable(sprintf('%d-09-01', $academicYearStartYear));
$recurrenceEnd = new DateTimeImmutable(sprintf('%d-06-30', $academicYearStartYear + 1));
foreach ($this->scheduleBlueprints() as $blueprint) {
foreach ($blueprint['assignments'] as $assignmentBlueprint) {
$class = $classes[$assignmentBlueprint['className']];
$subject = $subjects[$assignmentBlueprint['subjectCode']];
$teacher = $users[$teacherKeysBySubject[$assignmentBlueprint['subjectCode']]];
if ($this->scheduleSlotExists(
tenantId: $tenantId,
classId: $class->id,
subjectId: $subject->id,
teacherId: $teacher->id,
dayOfWeek: $blueprint['dayOfWeek'],
startTime: $blueprint['startTime'],
endTime: $blueprint['endTime'],
room: $assignmentBlueprint['room'],
recurrenceStart: $recurrenceStart,
recurrenceEnd: $recurrenceEnd,
)) {
continue;
}
$slot = ScheduleSlot::creer(
tenantId: $tenantId,
classId: $class->id,
subjectId: $subject->id,
teacherId: $teacher->id,
dayOfWeek: $blueprint['dayOfWeek'],
timeSlot: new TimeSlot($blueprint['startTime'], $blueprint['endTime']),
room: $assignmentBlueprint['room'],
isRecurring: true,
now: $now,
recurrenceStart: $recurrenceStart,
recurrenceEnd: $recurrenceEnd,
);
$this->scheduleSlotRepository->save($slot);
++$result->createdScheduleSlots;
}
}
}
private function scheduleSlotExists(
TenantId $tenantId,
\App\Administration\Domain\Model\SchoolClass\ClassId $classId,
\App\Administration\Domain\Model\Subject\SubjectId $subjectId,
UserId $teacherId,
DayOfWeek $dayOfWeek,
string $startTime,
string $endTime,
?string $room,
DateTimeImmutable $recurrenceStart,
DateTimeImmutable $recurrenceEnd,
): bool {
foreach ($this->scheduleSlotRepository->findRecurringByClass($classId, $tenantId) as $existingSlot) {
if (!$existingSlot->subjectId->equals($subjectId)) {
continue;
}
if (!$existingSlot->teacherId->equals($teacherId)) {
continue;
}
if ($existingSlot->dayOfWeek !== $dayOfWeek) {
continue;
}
if ($existingSlot->timeSlot->startTime !== $startTime || $existingSlot->timeSlot->endTime !== $endTime) {
continue;
}
if ($existingSlot->room !== $room) {
continue;
}
if ($existingSlot->recurrenceStart?->format('Y-m-d') !== $recurrenceStart->format('Y-m-d')) {
continue;
}
if ($existingSlot->recurrenceEnd?->format('Y-m-d') !== $recurrenceEnd->format('Y-m-d')) {
continue;
}
return true;
}
return false;
}
private function ensureActiveUser(
TenantConfig $tenantConfig,
string $schoolName,
string $hashedPassword,
Role $role,
string $emailSlug,
string $firstName,
string $lastName,
DateTimeImmutable $now,
DemoDataGenerationResult $result,
?DateTimeImmutable $dateOfBirth = null,
?string $studentNumber = null,
): User {
$email = new Email($this->emailFor($emailSlug, $tenantConfig->subdomain));
$user = $this->userRepository->findByEmail($email, $tenantConfig->tenantId);
if ($user === null) {
$user = User::reconstitute(
id: UserId::generate(),
email: $email,
roles: [$role],
tenantId: $tenantConfig->tenantId,
schoolName: $schoolName,
statut: StatutCompte::ACTIF,
dateNaissance: $dateOfBirth,
createdAt: $now,
hashedPassword: $hashedPassword,
activatedAt: $now,
consentementParental: null,
firstName: $firstName,
lastName: $lastName,
studentNumber: $studentNumber,
);
$this->userRepository->save($user);
++$result->createdUsers;
} else {
$userWasChanged = false;
if ($user->firstName !== $firstName || $user->lastName !== $lastName) {
$user->mettreAJourInfos($firstName, $lastName);
$userWasChanged = true;
}
if (!$user->aLeRole($role)) {
$user->attribuerRole($role, $now);
$userWasChanged = true;
}
if ($user->statut === StatutCompte::SUSPENDU) {
$user->debloquer($now);
$userWasChanged = true;
}
if ($user->statut === StatutCompte::ACTIF) {
$user->changerMotDePasse($hashedPassword, $now);
$userWasChanged = true;
} else {
$result->addWarning(sprintf(
'Le compte "%s" existe déjà avec le statut "%s". Le mot de passe commun n\'a pas pu être réinitialisé.',
(string) $email,
$user->statut->value,
));
}
if ($userWasChanged) {
$this->userRepository->save($user);
}
}
$result->addAccount(
role: $role->label(),
name: trim($firstName . ' ' . $lastName),
email: (string) $email,
);
return $user;
}
private function resolveAcademicYearId(): AcademicYearId
{
$resolved = $this->currentAcademicYearResolver->resolve('current');
if ($resolved === null) {
throw new RuntimeException('Impossible de résoudre l\'année scolaire courante.');
}
return AcademicYearId::fromString($resolved);
}
private function resolveAcademicYearStartYear(): int
{
$startYear = $this->currentAcademicYearResolver->resolveStartYear('current');
if ($startYear === null) {
throw new RuntimeException('Impossible de résoudre le millésime de l\'année scolaire courante.');
}
return $startYear;
}
private function emailFor(string $emailSlug, string $subdomain): string
{
return sprintf('%s.%s@classeo.test', strtolower($emailSlug), strtolower($subdomain));
}
/**
* @return list<array{key: string, role: Role, emailSlug: string, firstName: string, lastName: string}>
*/
private function staffBlueprints(): array
{
return [
[
'key' => 'admin',
'role' => Role::ADMIN,
'emailSlug' => 'admin.direction',
'firstName' => 'Camille',
'lastName' => 'Martin',
],
[
'key' => 'vie-scolaire',
'role' => Role::VIE_SCOLAIRE,
'emailSlug' => 'vie.scolaire',
'firstName' => 'Nora',
'lastName' => 'Petit',
],
[
'key' => 'secretariat',
'role' => Role::SECRETARIAT,
'emailSlug' => 'secretariat',
'firstName' => 'Lucie',
'lastName' => 'Bernard',
],
];
}
/**
* @return list<array{key: string, subjectCode: string, emailSlug: string, firstName: string, lastName: string}>
*/
private function teacherBlueprints(): array
{
return [
[
'key' => 'teacher-math',
'subjectCode' => 'MATH',
'emailSlug' => 'prof.amina.benali',
'firstName' => 'Amina',
'lastName' => 'Benali',
],
[
'key' => 'teacher-fr',
'subjectCode' => 'FR',
'emailSlug' => 'prof.julie.caron',
'firstName' => 'Julie',
'lastName' => 'Caron',
],
[
'key' => 'teacher-hg',
'subjectCode' => 'HG',
'emailSlug' => 'prof.marc.garcia',
'firstName' => 'Marc',
'lastName' => 'Garcia',
],
[
'key' => 'teacher-svt',
'subjectCode' => 'SVT',
'emailSlug' => 'prof.sophie.lambert',
'firstName' => 'Sophie',
'lastName' => 'Lambert',
],
[
'key' => 'teacher-ang',
'subjectCode' => 'ANG',
'emailSlug' => 'prof.david.nguyen',
'firstName' => 'David',
'lastName' => 'Nguyen',
],
[
'key' => 'teacher-eps',
'subjectCode' => 'EPS',
'emailSlug' => 'prof.clara.petit',
'firstName' => 'Clara',
'lastName' => 'Petit',
],
];
}
/**
* @return list<array{key: string, emailSlug: string, firstName: string, lastName: string}>
*/
private function parentBlueprints(): array
{
return [
['key' => 'parent-nadia-martin', 'emailSlug' => 'parent.nadia.martin', 'firstName' => 'Nadia', 'lastName' => 'Martin'],
['key' => 'parent-karim-martin', 'emailSlug' => 'parent.karim.martin', 'firstName' => 'Karim', 'lastName' => 'Martin'],
['key' => 'parent-ines-lopez', 'emailSlug' => 'parent.ines.lopez', 'firstName' => 'Ines', 'lastName' => 'Lopez'],
['key' => 'parent-raul-lopez', 'emailSlug' => 'parent.raul.lopez', 'firstName' => 'Raul', 'lastName' => 'Lopez'],
['key' => 'parent-claire-bernard', 'emailSlug' => 'parent.claire.bernard', 'firstName' => 'Claire', 'lastName' => 'Bernard'],
['key' => 'parent-mehdi-petit', 'emailSlug' => 'parent.mehdi.petit', 'firstName' => 'Mehdi', 'lastName' => 'Petit'],
['key' => 'parent-laura-moreau', 'emailSlug' => 'parent.laura.moreau', 'firstName' => 'Laura', 'lastName' => 'Moreau'],
['key' => 'parent-olivier-moreau', 'emailSlug' => 'parent.olivier.moreau', 'firstName' => 'Olivier', 'lastName' => 'Moreau'],
['key' => 'parent-celine-dupont', 'emailSlug' => 'parent.celine.dupont', 'firstName' => 'Celine', 'lastName' => 'Dupont'],
];
}
/**
* @return list<array{
* key: string,
* emailSlug: string,
* firstName: string,
* lastName: string,
* className: string,
* dateOfBirth: DateTimeImmutable,
* studentNumber: string
* }>
*/
private function studentBlueprints(): array
{
return [
['key' => 'student-lina-martin', 'emailSlug' => 'eleve.lina.martin', 'firstName' => 'Lina', 'lastName' => 'Martin', 'className' => '6A', 'dateOfBirth' => new DateTimeImmutable('2013-02-14'), 'studentNumber' => 'DEMO-001'],
['key' => 'student-chloe-lopez', 'emailSlug' => 'eleve.chloe.lopez', 'firstName' => 'Chloe', 'lastName' => 'Lopez', 'className' => '6A', 'dateOfBirth' => new DateTimeImmutable('2013-05-02'), 'studentNumber' => 'DEMO-002'],
['key' => 'student-hugo-lopez', 'emailSlug' => 'eleve.hugo.lopez', 'firstName' => 'Hugo', 'lastName' => 'Lopez', 'className' => '6A', 'dateOfBirth' => new DateTimeImmutable('2013-07-22'), 'studentNumber' => 'DEMO-003'],
['key' => 'student-zoe-moreau', 'emailSlug' => 'eleve.zoe.moreau', 'firstName' => 'Zoe', 'lastName' => 'Moreau', 'className' => '6A', 'dateOfBirth' => new DateTimeImmutable('2013-11-09'), 'studentNumber' => 'DEMO-004'],
['key' => 'student-jade-bernard', 'emailSlug' => 'eleve.jade.bernard', 'firstName' => 'Jade', 'lastName' => 'Bernard', 'className' => '5A', 'dateOfBirth' => new DateTimeImmutable('2012-01-18'), 'studentNumber' => 'DEMO-005'],
['key' => 'student-ethan-bernard', 'emailSlug' => 'eleve.ethan.bernard', 'firstName' => 'Ethan', 'lastName' => 'Bernard', 'className' => '5A', 'dateOfBirth' => new DateTimeImmutable('2012-03-27'), 'studentNumber' => 'DEMO-006'],
['key' => 'student-nino-moreau', 'emailSlug' => 'eleve.nino.moreau', 'firstName' => 'Nino', 'lastName' => 'Moreau', 'className' => '5A', 'dateOfBirth' => new DateTimeImmutable('2012-09-04'), 'studentNumber' => 'DEMO-007'],
['key' => 'student-sarah-dupont', 'emailSlug' => 'eleve.sarah.dupont', 'firstName' => 'Sarah', 'lastName' => 'Dupont', 'className' => '5A', 'dateOfBirth' => new DateTimeImmutable('2012-12-30'), 'studentNumber' => 'DEMO-008'],
['key' => 'student-noah-martin', 'emailSlug' => 'eleve.noah.martin', 'firstName' => 'Noah', 'lastName' => 'Martin', 'className' => '4A', 'dateOfBirth' => new DateTimeImmutable('2011-02-11'), 'studentNumber' => 'DEMO-009'],
['key' => 'student-adam-petit', 'emailSlug' => 'eleve.adam.petit', 'firstName' => 'Adam', 'lastName' => 'Petit', 'className' => '4A', 'dateOfBirth' => new DateTimeImmutable('2011-06-19'), 'studentNumber' => 'DEMO-010'],
['key' => 'student-yasmine-petit', 'emailSlug' => 'eleve.yasmine.petit', 'firstName' => 'Yasmine', 'lastName' => 'Petit', 'className' => '4A', 'dateOfBirth' => new DateTimeImmutable('2011-08-25'), 'studentNumber' => 'DEMO-011'],
['key' => 'student-mael-dupont', 'emailSlug' => 'eleve.mael.dupont', 'firstName' => 'Mael', 'lastName' => 'Dupont', 'className' => '4A', 'dateOfBirth' => new DateTimeImmutable('2011-10-03'), 'studentNumber' => 'DEMO-012'],
];
}
/**
* @return list<array{
* code: non-empty-string,
* name: non-empty-string,
* color: non-empty-string,
* description: non-empty-string
* }>
*/
private function subjectBlueprints(): array
{
return [
['code' => 'MATH', 'name' => 'Mathématiques', 'color' => '#2563EB', 'description' => 'Cours de mathématiques pour la démonstration.'],
['code' => 'FR', 'name' => 'Français', 'color' => '#DC2626', 'description' => 'Lecture, grammaire et expression écrite.'],
['code' => 'HG', 'name' => 'Histoire-Géographie', 'color' => '#D97706', 'description' => 'Repères historiques et géographiques.'],
['code' => 'SVT', 'name' => 'SVT', 'color' => '#16A34A', 'description' => 'Sciences de la vie et de la Terre.'],
['code' => 'ANG', 'name' => 'Anglais', 'color' => '#7C3AED', 'description' => 'Cours danglais pour tous les niveaux.'],
['code' => 'EPS', 'name' => 'EPS', 'color' => '#0891B2', 'description' => 'Éducation physique et sportive.'],
];
}
/**
* @return list<array{name: string, level: SchoolLevel, capacity: int, description: string}>
*/
private function classBlueprints(): array
{
return [
['name' => '6A', 'level' => SchoolLevel::SIXIEME, 'capacity' => 28, 'description' => 'Classe de sixième utilisée pour les démonstrations.'],
['name' => '5A', 'level' => SchoolLevel::CINQUIEME, 'capacity' => 28, 'description' => 'Classe de cinquième utilisée pour les démonstrations.'],
['name' => '4A', 'level' => SchoolLevel::QUATRIEME, 'capacity' => 28, 'description' => 'Classe de quatrième utilisée pour les démonstrations.'],
];
}
/**
* @return list<array{studentKey: string, guardianKey: string, relationshipType: RelationshipType}>
*/
private function guardianLinkBlueprints(): array
{
return [
['studentKey' => 'student-lina-martin', 'guardianKey' => 'parent-nadia-martin', 'relationshipType' => RelationshipType::MOTHER],
['studentKey' => 'student-lina-martin', 'guardianKey' => 'parent-karim-martin', 'relationshipType' => RelationshipType::FATHER],
['studentKey' => 'student-noah-martin', 'guardianKey' => 'parent-nadia-martin', 'relationshipType' => RelationshipType::MOTHER],
['studentKey' => 'student-noah-martin', 'guardianKey' => 'parent-karim-martin', 'relationshipType' => RelationshipType::FATHER],
['studentKey' => 'student-chloe-lopez', 'guardianKey' => 'parent-ines-lopez', 'relationshipType' => RelationshipType::MOTHER],
['studentKey' => 'student-chloe-lopez', 'guardianKey' => 'parent-raul-lopez', 'relationshipType' => RelationshipType::FATHER],
['studentKey' => 'student-hugo-lopez', 'guardianKey' => 'parent-ines-lopez', 'relationshipType' => RelationshipType::MOTHER],
['studentKey' => 'student-hugo-lopez', 'guardianKey' => 'parent-raul-lopez', 'relationshipType' => RelationshipType::FATHER],
['studentKey' => 'student-jade-bernard', 'guardianKey' => 'parent-claire-bernard', 'relationshipType' => RelationshipType::MOTHER],
['studentKey' => 'student-ethan-bernard', 'guardianKey' => 'parent-claire-bernard', 'relationshipType' => RelationshipType::MOTHER],
['studentKey' => 'student-adam-petit', 'guardianKey' => 'parent-mehdi-petit', 'relationshipType' => RelationshipType::FATHER],
['studentKey' => 'student-yasmine-petit', 'guardianKey' => 'parent-mehdi-petit', 'relationshipType' => RelationshipType::FATHER],
['studentKey' => 'student-zoe-moreau', 'guardianKey' => 'parent-laura-moreau', 'relationshipType' => RelationshipType::MOTHER],
['studentKey' => 'student-zoe-moreau', 'guardianKey' => 'parent-olivier-moreau', 'relationshipType' => RelationshipType::FATHER],
['studentKey' => 'student-nino-moreau', 'guardianKey' => 'parent-laura-moreau', 'relationshipType' => RelationshipType::MOTHER],
['studentKey' => 'student-nino-moreau', 'guardianKey' => 'parent-olivier-moreau', 'relationshipType' => RelationshipType::FATHER],
['studentKey' => 'student-sarah-dupont', 'guardianKey' => 'parent-celine-dupont', 'relationshipType' => RelationshipType::TUTOR_F],
['studentKey' => 'student-mael-dupont', 'guardianKey' => 'parent-celine-dupont', 'relationshipType' => RelationshipType::TUTOR_F],
];
}
/**
* @return list<array{
* dayOfWeek: DayOfWeek,
* startTime: string,
* endTime: string,
* assignments: list<array{className: string, subjectCode: string, room: string}>
* }>
*/
private function scheduleBlueprints(): array
{
return [
[
'dayOfWeek' => DayOfWeek::MONDAY,
'startTime' => '08:00',
'endTime' => '09:00',
'assignments' => [
['className' => '6A', 'subjectCode' => 'MATH', 'room' => 'B12'],
['className' => '5A', 'subjectCode' => 'FR', 'room' => 'B14'],
['className' => '4A', 'subjectCode' => 'HG', 'room' => 'B16'],
],
],
[
'dayOfWeek' => DayOfWeek::MONDAY,
'startTime' => '09:00',
'endTime' => '10:00',
'assignments' => [
['className' => '6A', 'subjectCode' => 'SVT', 'room' => 'LAB1'],
['className' => '5A', 'subjectCode' => 'ANG', 'room' => 'B15'],
['className' => '4A', 'subjectCode' => 'EPS', 'room' => 'GYM1'],
],
],
[
'dayOfWeek' => DayOfWeek::TUESDAY,
'startTime' => '08:00',
'endTime' => '09:00',
'assignments' => [
['className' => '6A', 'subjectCode' => 'FR', 'room' => 'B13'],
['className' => '5A', 'subjectCode' => 'HG', 'room' => 'B16'],
['className' => '4A', 'subjectCode' => 'MATH', 'room' => 'B12'],
],
],
[
'dayOfWeek' => DayOfWeek::TUESDAY,
'startTime' => '09:00',
'endTime' => '10:00',
'assignments' => [
['className' => '6A', 'subjectCode' => 'ANG', 'room' => 'B15'],
['className' => '5A', 'subjectCode' => 'EPS', 'room' => 'GYM1'],
['className' => '4A', 'subjectCode' => 'SVT', 'room' => 'LAB1'],
],
],
[
'dayOfWeek' => DayOfWeek::THURSDAY,
'startTime' => '13:00',
'endTime' => '14:00',
'assignments' => [
['className' => '6A', 'subjectCode' => 'HG', 'room' => 'B16'],
['className' => '5A', 'subjectCode' => 'MATH', 'room' => 'B12'],
['className' => '4A', 'subjectCode' => 'FR', 'room' => 'B14'],
],
],
[
'dayOfWeek' => DayOfWeek::FRIDAY,
'startTime' => '14:00',
'endTime' => '15:00',
'assignments' => [
['className' => '6A', 'subjectCode' => 'EPS', 'room' => 'GYM1'],
['className' => '5A', 'subjectCode' => 'SVT', 'room' => 'LAB1'],
['className' => '4A', 'subjectCode' => 'ANG', 'room' => 'B15'],
],
],
];
}
/**
* @return array<string, string>
*/
private function teacherKeysBySubject(): array
{
$mapping = [];
foreach ($this->teacherBlueprints() as $blueprint) {
$mapping[$blueprint['subjectCode']] = $blueprint['key'];
}
return $mapping;
}
public function defaultSchoolNameForSubdomain(string $subdomain): string
{
return sprintf(
'Établissement de démo %s',
ucwords(str_replace('-', ' ', trim($subdomain))),
);
}
}

View File

@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Service;
use App\Administration\Application\Port\OfficialCalendarProvider;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntry;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryId;
use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolZone;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Repository\GradingConfigurationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryGradingConfigurationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryStudentGuardianRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Administration\Infrastructure\Service\DemoDataGenerator;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class DemoDataGeneratorTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440123';
private const string TENANT_SUBDOMAIN = 'demo';
private InMemoryUserRepository $userRepository;
private InMemoryClassRepository $classRepository;
private InMemorySubjectRepository $subjectRepository;
private InMemoryClassAssignmentRepository $classAssignmentRepository;
private InMemoryTeacherAssignmentRepository $teacherAssignmentRepository;
private InMemoryStudentGuardianRepository $studentGuardianRepository;
private InMemoryScheduleSlotRepository $scheduleSlotRepository;
private InMemoryPeriodConfigurationRepository $periodConfigurationRepository;
private InMemorySchoolCalendarRepository $schoolCalendarRepository;
private GradingConfigurationRepository $gradingConfigurationRepository;
private DemoDataGenerator $generator;
private TenantConfig $tenantConfig;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$this->classRepository = new InMemoryClassRepository();
$this->subjectRepository = new InMemorySubjectRepository();
$this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
$this->teacherAssignmentRepository = new InMemoryTeacherAssignmentRepository();
$this->studentGuardianRepository = new InMemoryStudentGuardianRepository();
$this->scheduleSlotRepository = new InMemoryScheduleSlotRepository();
$this->periodConfigurationRepository = new InMemoryPeriodConfigurationRepository();
$this->schoolCalendarRepository = new InMemorySchoolCalendarRepository();
$this->gradingConfigurationRepository = new InMemoryGradingConfigurationRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-10 10:00:00');
}
};
$tenantContext = new TenantContext();
$currentAcademicYearResolver = new CurrentAcademicYearResolver($tenantContext, $clock);
$passwordHasher = new class implements PasswordHasher {
public function hash(string $plainPassword): string
{
return 'hashed_' . $plainPassword;
}
public function verify(string $hashedPassword, string $plainPassword): bool
{
return $hashedPassword === 'hashed_' . $plainPassword;
}
};
$officialCalendarProvider = new class implements OfficialCalendarProvider {
public function joursFeries(string $academicYear): array
{
return [
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::HOLIDAY,
startDate: new DateTimeImmutable('2025-11-11'),
endDate: new DateTimeImmutable('2025-11-11'),
label: 'Armistice',
),
];
}
public function vacancesParZone(SchoolZone $zone, string $academicYear): array
{
return [
new CalendarEntry(
id: CalendarEntryId::generate(),
type: CalendarEntryType::VACATION,
startDate: new DateTimeImmutable('2025-12-20'),
endDate: new DateTimeImmutable('2026-01-04'),
label: 'Vacances de Noël',
),
];
}
public function toutesEntreesOfficielles(SchoolZone $zone, string $academicYear): array
{
return [
...$this->joursFeries($academicYear),
...$this->vacancesParZone($zone, $academicYear),
];
}
};
$this->generator = new DemoDataGenerator(
userRepository: $this->userRepository,
classRepository: $this->classRepository,
subjectRepository: $this->subjectRepository,
classAssignmentRepository: $this->classAssignmentRepository,
teacherAssignmentRepository: $this->teacherAssignmentRepository,
studentGuardianRepository: $this->studentGuardianRepository,
scheduleSlotRepository: $this->scheduleSlotRepository,
periodConfigurationRepository: $this->periodConfigurationRepository,
schoolCalendarRepository: $this->schoolCalendarRepository,
gradingConfigurationRepository: $this->gradingConfigurationRepository,
passwordHasher: $passwordHasher,
clock: $clock,
tenantContext: $tenantContext,
currentAcademicYearResolver: $currentAcademicYearResolver,
schoolIdResolver: new SchoolIdResolver(),
officialCalendarProvider: $officialCalendarProvider,
);
$this->tenantConfig = new TenantConfig(
tenantId: TenantId::fromString(self::TENANT_ID),
subdomain: self::TENANT_SUBDOMAIN,
databaseUrl: 'postgresql://localhost/demo',
);
}
#[Test]
public function itGeneratesACompleteDemoDataset(): void
{
$result = $this->generator->generate(
tenantConfig: $this->tenantConfig,
password: 'DemoPassword123!',
schoolName: 'Établissement Démo',
zone: SchoolZone::B,
periodType: PeriodType::TRIMESTER,
);
self::assertSame(30, $result->createdUsers);
self::assertSame(6, $result->createdSubjects);
self::assertSame(3, $result->createdClasses);
self::assertSame(12, $result->createdClassAssignments);
self::assertSame(18, $result->createdTeacherAssignments);
self::assertSame(18, $result->createdGuardianLinks);
self::assertSame(18, $result->createdScheduleSlots);
self::assertTrue($result->periodConfigurationCreated);
self::assertTrue($result->schoolCalendarCreated);
self::assertTrue($result->gradingConfigurationCreated);
self::assertCount(30, $result->accounts);
self::assertSame('2025-2026', $result->academicYearLabel);
self::assertCount(30, $this->userRepository->findAllByTenant($this->tenantConfig->tenantId));
self::assertCount(12, $this->userRepository->findStudentsByTenant($this->tenantConfig->tenantId));
$student = $this->userRepository->findByEmail(
new Email('eleve.lina.martin.demo@classeo.test'),
$this->tenantConfig->tenantId,
);
self::assertNotNull($student);
self::assertSame('hashed_DemoPassword123!', $student->hashedPassword);
self::assertSame('DEMO-001', $student->studentNumber);
$mathTeacher = $this->userRepository->findByEmail(
new Email('prof.amina.benali.demo@classeo.test'),
$this->tenantConfig->tenantId,
);
self::assertNotNull($mathTeacher);
$currentYearId = $this->resolveCurrentAcademicYearId();
self::assertCount(
3,
$this->classRepository->findActiveByTenantAndYear($this->tenantConfig->tenantId, $currentYearId),
);
self::assertCount(6, $this->subjectRepository->findActiveByTenantAndSchool(
$this->tenantConfig->tenantId,
\App\Administration\Domain\Model\SchoolClass\SchoolId::fromString(
(new SchoolIdResolver())->resolveForTenant(self::TENANT_ID),
),
));
self::assertCount(
18,
$this->teacherAssignmentRepository->findAllActiveByTenant($this->tenantConfig->tenantId),
);
self::assertCount(
2,
$this->studentGuardianRepository->findGuardiansForStudent($student->id, $this->tenantConfig->tenantId),
);
$calendar = $this->schoolCalendarRepository->findByTenantAndYear($this->tenantConfig->tenantId, $currentYearId);
self::assertNotNull($calendar);
self::assertCount(2, $calendar->entries());
$periodConfiguration = $this->periodConfigurationRepository->findByAcademicYear(
$this->tenantConfig->tenantId,
$currentYearId,
);
self::assertNotNull($periodConfiguration);
self::assertSame(PeriodType::TRIMESTER, $periodConfiguration->type);
}
#[Test]
public function itIsIdempotentWhenRunTwice(): void
{
$this->generator->generate(
tenantConfig: $this->tenantConfig,
password: 'DemoPassword123!',
schoolName: 'Établissement Démo',
zone: SchoolZone::B,
periodType: PeriodType::TRIMESTER,
);
$result = $this->generator->generate(
tenantConfig: $this->tenantConfig,
password: 'DemoPassword123!',
schoolName: 'Établissement Démo',
zone: SchoolZone::B,
periodType: PeriodType::TRIMESTER,
);
self::assertSame(0, $result->createdUsers);
self::assertSame(0, $result->createdSubjects);
self::assertSame(0, $result->createdClasses);
self::assertSame(0, $result->createdClassAssignments);
self::assertSame(0, $result->createdTeacherAssignments);
self::assertSame(0, $result->createdGuardianLinks);
self::assertSame(0, $result->createdScheduleSlots);
self::assertFalse($result->periodConfigurationCreated);
self::assertFalse($result->schoolCalendarCreated);
self::assertFalse($result->gradingConfigurationCreated);
self::assertCount(30, $result->accounts);
}
private function resolveCurrentAcademicYearId(): \App\Administration\Domain\Model\SchoolClass\AcademicYearId
{
$tenantContext = new TenantContext();
$tenantContext->setCurrentTenant($this->tenantConfig);
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-10 10:00:00');
}
};
$resolver = new CurrentAcademicYearResolver($tenantContext, $clock);
return \App\Administration\Domain\Model\SchoolClass\AcademicYearId::fromString(
$resolver->resolve('current') ?? throw new RuntimeException('Missing academic year'),
);
}
}