feat(demo): add tenant demo data generator
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:
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user