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