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.
237 lines
8.4 KiB
PHP
237 lines
8.4 KiB
PHP
<?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,
|
|
);
|
|
}
|
|
}
|