feat: Provisionner automatiquement un nouvel établissement
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

Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
This commit is contained in:
2026-04-08 13:55:41 +02:00
parent bec211ebf0
commit 3575d095a1
106 changed files with 9586 additions and 380 deletions

View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
use App\Administration\Domain\Event\InvitationRenvoyee;
use App\Administration\Domain\Event\UtilisateurInvite;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
use App\SuperAdmin\Application\Port\TenantProvisioner;
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use RuntimeException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class ProvisionEstablishmentHandlerTest extends TestCase
{
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master?serverVersion=18';
private const string ESTABLISHMENT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
#[Test]
public function itProvisionsTenantDatabase(): void
{
$provisioner = $this->createMock(TenantProvisioner::class);
$provisioner->expects(self::once())
->method('provision')
->with('classeo_tenant_abc123');
$handler = $this->buildHandler(provisioner: $provisioner);
$handler($this->command());
}
#[Test]
public function itCreatesAdminUser(): void
{
$userRepository = new InMemoryUserRepository();
$handler = $this->buildHandler(userRepository: $userRepository);
$handler($this->command());
$users = $userRepository->findAllByTenant(TenantId::fromString(self::TENANT_ID));
self::assertCount(1, $users);
self::assertSame('admin@ecole-gamma.fr', (string) $users[0]->email);
}
#[Test]
public function itDispatchesInvitationEvent(): void
{
$dispatched = [];
$eventBus = $this->spyEventBus($dispatched);
$handler = $this->buildHandler(eventBus: $eventBus);
$handler($this->command());
self::assertNotEmpty($dispatched);
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
}
#[Test]
public function itActivatesEstablishmentAfterProvisioning(): void
{
$establishmentRepo = $this->establishmentRepoWithProvisioningEstablishment();
$handler = $this->buildHandler(establishmentRepository: $establishmentRepo);
$handler($this->command());
$establishment = $establishmentRepo->get(
EstablishmentId::fromString(self::ESTABLISHMENT_ID),
);
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
}
#[Test]
public function itIsIdempotentWhenAdminAlreadyExists(): void
{
$userRepository = new InMemoryUserRepository();
$dispatched = [];
$eventBus = $this->spyEventBus($dispatched);
$handler = $this->buildHandler(userRepository: $userRepository, eventBus: $eventBus);
// First call creates the admin
$handler($this->command());
self::assertCount(1, $dispatched);
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
// Second call is idempotent — re-sends invitation
$dispatched = [];
$handler($this->command());
self::assertCount(1, $dispatched);
self::assertInstanceOf(InvitationRenvoyee::class, $dispatched[0]);
}
#[Test]
public function itSwitchesDatabaseAndRestores(): void
{
$switcher = new SpyDatabaseSwitcher();
$handler = $this->buildHandler(databaseSwitcher: $switcher);
$handler($this->command());
self::assertCount(1, $switcher->switchedTo);
self::assertStringContainsString('classeo_tenant_abc123', $switcher->switchedTo[0]);
self::assertTrue($switcher->restoredToDefault);
}
#[Test]
public function itPreservesQueryParametersInDatabaseUrl(): void
{
$switcher = new SpyDatabaseSwitcher();
$handler = $this->buildHandler(databaseSwitcher: $switcher);
$handler($this->command());
self::assertStringContainsString('?serverVersion=18', $switcher->switchedTo[0]);
}
#[Test]
public function itRestoresDatabaseEvenOnFailure(): void
{
$switcher = new SpyDatabaseSwitcher();
$eventBus = $this->createMock(MessageBusInterface::class);
$eventBus->method('dispatch')
->willThrowException(new RuntimeException('Event bus failure'));
$handler = $this->buildHandler(databaseSwitcher: $switcher, eventBus: $eventBus);
try {
$handler($this->command());
} catch (RuntimeException) {
// Expected
}
self::assertTrue($switcher->restoredToDefault);
}
private function command(): ProvisionEstablishmentCommand
{
return new ProvisionEstablishmentCommand(
establishmentId: self::ESTABLISHMENT_ID,
establishmentTenantId: self::TENANT_ID,
databaseName: 'classeo_tenant_abc123',
subdomain: 'ecole-gamma',
adminEmail: 'admin@ecole-gamma.fr',
establishmentName: 'École Gamma',
);
}
private function establishmentRepoWithProvisioningEstablishment(): InMemoryEstablishmentRepository
{
$repo = new InMemoryEstablishmentRepository();
$establishment = Establishment::reconstitute(
id: EstablishmentId::fromString(self::ESTABLISHMENT_ID),
tenantId: TenantId::fromString(self::TENANT_ID),
name: 'École Gamma',
subdomain: 'ecole-gamma',
databaseName: 'classeo_tenant_abc123',
status: EstablishmentStatus::PROVISIONING,
createdAt: new DateTimeImmutable('2026-04-07 10:00:00'),
createdBy: SuperAdminId::fromString('550e8400-e29b-41d4-a716-446655440002'),
);
$repo->save($establishment);
return $repo;
}
/**
* @param object[] $dispatched
*/
private function spyEventBus(array &$dispatched): MessageBusInterface
{
$eventBus = $this->createMock(MessageBusInterface::class);
$eventBus->method('dispatch')
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
$dispatched[] = $message;
return new Envelope($message);
});
return $eventBus;
}
private function buildHandler(
?TenantProvisioner $provisioner = null,
?InMemoryUserRepository $userRepository = null,
?SpyDatabaseSwitcher $databaseSwitcher = null,
?InMemoryEstablishmentRepository $establishmentRepository = null,
?MessageBusInterface $eventBus = null,
): ProvisionEstablishmentHandler {
$provisioner ??= $this->createMock(TenantProvisioner::class);
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-04-07 10:00:00');
}
};
$userRepository ??= new InMemoryUserRepository();
$databaseSwitcher ??= new SpyDatabaseSwitcher();
$establishmentRepository ??= $this->establishmentRepoWithProvisioningEstablishment();
$eventBus ??= $this->createMock(MessageBusInterface::class);
$eventBus->method('dispatch')
->willReturnCallback(static fn (object $m): Envelope => new Envelope($m));
return new ProvisionEstablishmentHandler(
tenantProvisioner: $provisioner,
inviteUserHandler: new InviteUserHandler($userRepository, $clock),
userRepository: $userRepository,
clock: $clock,
databaseSwitcher: $databaseSwitcher,
establishmentRepository: $establishmentRepository,
eventBus: $eventBus,
logger: new NullLogger(),
masterDatabaseUrl: self::MASTER_URL,
);
}
}