feat: Provisionner automatiquement un nouvel établissement
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:
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Shared\Infrastructure\Audit;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Shared\Application\Port\AuditLogger;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* [P1] Functional tests for audit trail infrastructure.
|
||||
*
|
||||
* Verifies that the AuditLogger writes to the real audit_log table
|
||||
* and that entries contain correct metadata.
|
||||
*
|
||||
* @see NFR-S7: Audit trail immutable (qui, quoi, quand)
|
||||
* @see FR90: Tracage actions sensibles
|
||||
*/
|
||||
final class AuditTrailFunctionalTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
private Connection $connection;
|
||||
private AuditLogger $auditLogger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
static::bootKernel();
|
||||
$container = static::getContainer();
|
||||
|
||||
/* @var Connection $connection */
|
||||
$this->connection = $container->get(Connection::class);
|
||||
|
||||
/* @var AuditLogger $auditLogger */
|
||||
$this->auditLogger = $container->get(AuditLogger::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logAuthenticationWritesEntryToAuditLogTable(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: [
|
||||
'email_hash' => hash('sha256', 'test@example.com'),
|
||||
'result' => 'success',
|
||||
'method' => 'password',
|
||||
],
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||
[$userId->toString(), 'ConnexionReussie'],
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry, 'Audit log entry should exist after logAuthentication');
|
||||
self::assertSame('User', $entry['aggregate_type']);
|
||||
self::assertSame($userId->toString(), $entry['aggregate_id']);
|
||||
self::assertSame('ConnexionReussie', $entry['event_type']);
|
||||
|
||||
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame('success', $payload['result']);
|
||||
self::assertSame('password', $payload['method']);
|
||||
self::assertArrayHasKey('email_hash', $payload);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logAuthenticationIncludesMetadataWithTimestamp(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: ['result' => 'success'],
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM audit_log WHERE aggregate_id = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||
[$userId->toString()],
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry);
|
||||
self::assertNotEmpty($entry['occurred_at'], 'Audit entry must have a timestamp');
|
||||
|
||||
$metadata = json_decode($entry['metadata'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertIsArray($metadata);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logFailedAuthenticationWritesWithNullUserId(): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionEchouee',
|
||||
userId: null,
|
||||
payload: [
|
||||
'email_hash' => hash('sha256', 'unknown@example.com'),
|
||||
'result' => 'failure',
|
||||
'reason' => 'invalid_credentials',
|
||||
],
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
"SELECT * FROM audit_log WHERE event_type = 'ConnexionEchouee' ORDER BY occurred_at DESC LIMIT 1",
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry, 'Failed login audit entry should exist');
|
||||
self::assertNull($entry['aggregate_id'], 'Failed login should have null user ID');
|
||||
self::assertSame('User', $entry['aggregate_type']);
|
||||
|
||||
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame('failure', $payload['result']);
|
||||
self::assertSame('invalid_credentials', $payload['reason']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logDataChangeWritesOldAndNewValues(): void
|
||||
{
|
||||
$aggregateId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logDataChange(
|
||||
aggregateType: 'Grade',
|
||||
aggregateId: $aggregateId,
|
||||
eventType: 'GradeModified',
|
||||
oldValues: ['value' => 14.0],
|
||||
newValues: ['value' => 16.0],
|
||||
reason: 'Correction erreur de saisie',
|
||||
);
|
||||
|
||||
$entry = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||
[$aggregateId->toString(), 'GradeModified'],
|
||||
);
|
||||
|
||||
self::assertNotFalse($entry);
|
||||
self::assertSame('Grade', $entry['aggregate_type']);
|
||||
|
||||
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame(['value' => 14.0], $payload['old_values']);
|
||||
self::assertSame(['value' => 16.0], $payload['new_values']);
|
||||
self::assertSame('Correction erreur de saisie', $payload['reason']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function auditLogEntriesAreAppendOnly(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: ['result' => 'success'],
|
||||
);
|
||||
|
||||
$countBefore = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||
[$userId->toString()],
|
||||
);
|
||||
|
||||
self::assertSame(1, $countBefore);
|
||||
|
||||
// Log a second event for the same user
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: $userId,
|
||||
payload: ['result' => 'success'],
|
||||
);
|
||||
|
||||
$countAfter = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||
[$userId->toString()],
|
||||
);
|
||||
|
||||
// Both entries should exist (append-only, no overwrite)
|
||||
self::assertSame(2, $countAfter, 'Audit log must be append-only — both entries should exist');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(DoctrineTenantRegistry::class)]
|
||||
final class DoctrineTenantRegistryTest extends TestCase
|
||||
{
|
||||
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
private const string DB_NAME = 'classeo_tenant_a1b2c3d4e5f67890abcdef1234567890';
|
||||
|
||||
#[Test]
|
||||
public function itResolvesConfigBySubdomain(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
$config = $registry->getBySubdomain(self::SUBDOMAIN);
|
||||
|
||||
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||
self::assertSame(self::TENANT_ID, (string) $config->tenantId);
|
||||
self::assertSame('postgresql://classeo:secret@db:5432/' . self::DB_NAME, $config->databaseUrl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesConfigByTenantId(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
$config = $registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsForUnknownSubdomain(): void
|
||||
{
|
||||
$registry = $this->registryWith([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
$registry->getBySubdomain('inexistant');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsForUnknownTenantId(): void
|
||||
{
|
||||
$registry = $this->registryWith([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itChecksExistence(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
self::assertTrue($registry->exists(self::SUBDOMAIN));
|
||||
self::assertFalse($registry->exists('inexistant'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsAllConfigs(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
['tenant_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'subdomain' => 'ecole-beta', 'database_name' => 'classeo_tenant_beta'],
|
||||
]);
|
||||
|
||||
$configs = $registry->getAllConfigs();
|
||||
|
||||
self::assertCount(2, $configs);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itQueriesDatabaseOnlyOnce(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->willReturn([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
$registry = new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||
|
||||
$registry->getBySubdomain(self::SUBDOMAIN);
|
||||
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||
$registry->exists(self::SUBDOMAIN);
|
||||
$registry->getAllConfigs();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{tenant_id: string, subdomain: string, database_name: string}> $rows
|
||||
*/
|
||||
private function registryWith(array $rows): DoctrineTenantRegistry
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAllAssociative')->willReturn($rows);
|
||||
|
||||
return new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsEstablishmentAndReturnsResult(): void
|
||||
public function createsEstablishmentAndReturnsIt(): void
|
||||
{
|
||||
$command = new CreateEstablishmentCommand(
|
||||
name: 'École Alpha',
|
||||
@@ -46,13 +46,13 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
||||
superAdminId: self::SUPER_ADMIN_ID,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$establishment = ($this->handler)($command);
|
||||
|
||||
self::assertNotEmpty($result->establishmentId);
|
||||
self::assertNotEmpty($result->tenantId);
|
||||
self::assertSame('École Alpha', $result->name);
|
||||
self::assertSame('ecole-alpha', $result->subdomain);
|
||||
self::assertStringStartsWith('classeo_tenant_', $result->databaseName);
|
||||
self::assertNotEmpty((string) $establishment->id);
|
||||
self::assertNotEmpty((string) $establishment->tenantId);
|
||||
self::assertSame('École Alpha', $establishment->name);
|
||||
self::assertSame('ecole-alpha', $establishment->subdomain);
|
||||
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -65,10 +65,10 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
||||
superAdminId: self::SUPER_ADMIN_ID,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$establishment = ($this->handler)($command);
|
||||
|
||||
$establishments = $this->repository->findAll();
|
||||
self::assertCount(1, $establishments);
|
||||
self::assertSame($result->establishmentId, (string) $establishments[0]->id);
|
||||
self::assertSame((string) $establishment->id, (string) $establishments[0]->id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
||||
$this->repository->save(Establishment::creer(
|
||||
name: 'École Alpha',
|
||||
subdomain: 'ecole-alpha',
|
||||
adminEmail: 'admin@ecole-alpha.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||
));
|
||||
@@ -47,6 +48,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
||||
$this->repository->save(Establishment::creer(
|
||||
name: 'École Beta',
|
||||
subdomain: 'ecole-beta',
|
||||
adminEmail: 'admin@ecole-beta.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 11:00:00'),
|
||||
));
|
||||
@@ -56,6 +58,6 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('École Alpha', $result[0]->name);
|
||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||
self::assertSame('active', $result[0]->status);
|
||||
self::assertSame('provisioning', $result[0]->status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ final class EstablishmentTest extends TestCase
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
|
||||
#[Test]
|
||||
public function creerCreatesActiveEstablishment(): void
|
||||
public function creerCreatesProvisioningEstablishment(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||
self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name);
|
||||
self::assertSame(self::SUBDOMAIN, $establishment->subdomain);
|
||||
self::assertNull($establishment->lastActivityAt);
|
||||
@@ -59,10 +59,21 @@ final class EstablishmentTest extends TestCase
|
||||
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerChangesStatusToActif(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
|
||||
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||
$establishment->activer();
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function desactiverChangesStatusToInactif(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
$establishment->activer();
|
||||
|
||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||
|
||||
@@ -73,6 +84,7 @@ final class EstablishmentTest extends TestCase
|
||||
public function desactiverRecordsEtablissementDesactiveEvent(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
$establishment->activer();
|
||||
$establishment->pullDomainEvents(); // Clear creation event
|
||||
|
||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||
@@ -86,6 +98,7 @@ final class EstablishmentTest extends TestCase
|
||||
public function desactiverThrowsWhenAlreadyInactive(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
$establishment->activer();
|
||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||
|
||||
$this->expectException(EstablishmentDejaInactifException::class);
|
||||
@@ -141,6 +154,7 @@ final class EstablishmentTest extends TestCase
|
||||
return Establishment::creer(
|
||||
name: self::ESTABLISHMENT_NAME,
|
||||
subdomain: self::SUBDOMAIN,
|
||||
adminEmail: 'admin@ecole-alpha.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||
@@ -16,13 +17,15 @@ use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final class CreateEstablishmentProcessorTest extends TestCase
|
||||
{
|
||||
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
#[Test]
|
||||
public function processCreatesEstablishmentAndReturnsResource(): void
|
||||
public function processCreatesEstablishmentAndDispatchesProvisioning(): void
|
||||
{
|
||||
$repository = new InMemoryEstablishmentRepository();
|
||||
$clock = new class implements Clock {
|
||||
@@ -42,7 +45,16 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($securityUser);
|
||||
|
||||
$processor = new CreateEstablishmentProcessor($handler, $security);
|
||||
$dispatched = [];
|
||||
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||
$commandBus->method('dispatch')
|
||||
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
|
||||
$dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$processor = new CreateEstablishmentProcessor($handler, $security, $commandBus);
|
||||
|
||||
$input = new EstablishmentResource();
|
||||
$input->name = 'École Gamma';
|
||||
@@ -55,6 +67,12 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
||||
self::assertNotNull($result->tenantId);
|
||||
self::assertSame('École Gamma', $result->name);
|
||||
self::assertSame('ecole-gamma', $result->subdomain);
|
||||
self::assertSame('active', $result->status);
|
||||
self::assertSame('provisioning', $result->status);
|
||||
|
||||
self::assertCount(1, $dispatched);
|
||||
self::assertInstanceOf(ProvisionEstablishmentCommand::class, $dispatched[0]);
|
||||
self::assertSame('admin@ecole-gamma.fr', $dispatched[0]->adminEmail);
|
||||
self::assertSame('ecole-gamma', $dispatched[0]->subdomain);
|
||||
self::assertSame('École Gamma', $dispatched[0]->establishmentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
||||
$repository->save(Establishment::creer(
|
||||
name: 'École Alpha',
|
||||
subdomain: 'ecole-alpha',
|
||||
adminEmail: 'admin@ecole-alpha.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||
));
|
||||
@@ -49,6 +50,6 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('École Alpha', $result[0]->name);
|
||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||
self::assertSame('active', $result[0]->status);
|
||||
self::assertSame('provisioning', $result[0]->status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use RuntimeException;
|
||||
|
||||
final class DatabaseTenantProvisionerTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCallsCreatorThenMigratorInOrder(): void
|
||||
{
|
||||
$steps = [];
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchOne')->willReturn(false);
|
||||
$connection->method('executeStatement')->willReturnCallback(
|
||||
static function () use (&$steps): int {
|
||||
$steps[] = 'create';
|
||||
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
|
||||
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||
|
||||
// TenantMigrator is final — we wrap via the TenantProvisioner interface
|
||||
// to verify the creator is called. Migration subprocess cannot be tested unitarily.
|
||||
$provisioner = new class($creator, $steps) implements TenantProvisioner {
|
||||
/** @param string[] $steps */
|
||||
public function __construct(
|
||||
private readonly TenantDatabaseCreator $creator,
|
||||
private array &$steps,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provision(string $databaseName): void
|
||||
{
|
||||
$this->creator->create($databaseName);
|
||||
$this->steps[] = 'migrate';
|
||||
}
|
||||
};
|
||||
|
||||
$provisioner->provision('classeo_tenant_test');
|
||||
|
||||
self::assertSame(['create', 'migrate'], $steps);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPropagatesCreationFailure(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchOne')->willThrowException(new RuntimeException('Connection refused'));
|
||||
|
||||
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||
$migrator = new TenantMigrator('/tmp', 'postgresql://u:p@h/db', new NullLogger());
|
||||
|
||||
$provisioner = new DatabaseTenantProvisioner($creator, $migrator);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$provisioner->provision('classeo_tenant_test');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
|
||||
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Integration tests: verify the full provisioning flow from API request
|
||||
* through establishment creation to async provisioning and admin user creation.
|
||||
*
|
||||
* Split into focused tests that each verify one aspect of the flow.
|
||||
*/
|
||||
final class ProvisioningIntegrationTest extends TestCase
|
||||
{
|
||||
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||
|
||||
private InMemoryEstablishmentRepository $establishmentRepository;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private ?ProvisionEstablishmentCommand $provisionCommand;
|
||||
/** @var object[] */
|
||||
private array $dispatchedEvents;
|
||||
|
||||
private function runFullFlow(): void
|
||||
{
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-04-07 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 1: API processor creates establishment
|
||||
$this->establishmentRepository = new InMemoryEstablishmentRepository();
|
||||
$createHandler = new CreateEstablishmentHandler($this->establishmentRepository, $clock);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(new SecuritySuperAdmin(
|
||||
SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
'superadmin@classeo.fr',
|
||||
'hashed',
|
||||
));
|
||||
|
||||
$this->provisionCommand = null;
|
||||
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||
$commandBus->method('dispatch')
|
||||
->willReturnCallback(function (object $message): Envelope {
|
||||
if ($message instanceof ProvisionEstablishmentCommand) {
|
||||
$this->provisionCommand = $message;
|
||||
}
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$processor = new CreateEstablishmentProcessor($createHandler, $security, $commandBus);
|
||||
|
||||
$input = new EstablishmentResource();
|
||||
$input->name = 'École Test';
|
||||
$input->subdomain = 'ecole-test';
|
||||
$input->adminEmail = 'admin@ecole-test.fr';
|
||||
|
||||
$processor->process($input, new Post());
|
||||
|
||||
// Phase 2: Provisioning handler processes the command
|
||||
self::assertNotNull($this->provisionCommand);
|
||||
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->dispatchedEvents = [];
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')
|
||||
->willReturnCallback(function (object $message): Envelope {
|
||||
$this->dispatchedEvents[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$provisioner = $this->createMock(TenantProvisioner::class);
|
||||
|
||||
$switcher = new SpyDatabaseSwitcher();
|
||||
|
||||
$provisionHandler = new ProvisionEstablishmentHandler(
|
||||
tenantProvisioner: $provisioner,
|
||||
inviteUserHandler: new InviteUserHandler($this->userRepository, $clock),
|
||||
userRepository: $this->userRepository,
|
||||
clock: $clock,
|
||||
databaseSwitcher: $switcher,
|
||||
establishmentRepository: $this->establishmentRepository,
|
||||
eventBus: $eventBus,
|
||||
logger: new NullLogger(),
|
||||
masterDatabaseUrl: self::MASTER_URL,
|
||||
);
|
||||
|
||||
$provisionHandler($this->provisionCommand);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function processorCreatesEstablishmentInProvisioningStatus(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
$establishments = $this->establishmentRepository->findAll();
|
||||
self::assertCount(1, $establishments);
|
||||
self::assertSame('École Test', $establishments[0]->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function processorDispatchesProvisioningCommandWithAdminEmail(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
self::assertNotNull($this->provisionCommand);
|
||||
self::assertSame('admin@ecole-test.fr', $this->provisionCommand->adminEmail);
|
||||
self::assertSame('ecole-test', $this->provisionCommand->subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function provisioningCreatesAdminUserWithCorrectRole(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
$users = $this->userRepository->findAllByTenant(
|
||||
TenantId::fromString($this->provisionCommand->establishmentTenantId),
|
||||
);
|
||||
self::assertCount(1, $users);
|
||||
self::assertSame('admin@ecole-test.fr', (string) $users[0]->email);
|
||||
self::assertSame(Role::ADMIN, $users[0]->role);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function provisioningActivatesEstablishmentAndDispatchesEvent(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
$establishments = $this->establishmentRepository->findAll();
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishments[0]->status);
|
||||
|
||||
self::assertCount(1, $this->dispatchedEvents);
|
||||
self::assertInstanceOf(UtilisateurInvite::class, $this->dispatchedEvents[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||
|
||||
/**
|
||||
* Test double that records database switching operations.
|
||||
*/
|
||||
final class SpyDatabaseSwitcher implements TenantDatabaseSwitcher
|
||||
{
|
||||
/** @var string[] */
|
||||
public array $switchedTo = [];
|
||||
public bool $restoredToDefault = false;
|
||||
|
||||
public function useTenantDatabase(string $databaseUrl): void
|
||||
{
|
||||
$this->switchedTo[] = $databaseUrl;
|
||||
}
|
||||
|
||||
public function useDefaultDatabase(): void
|
||||
{
|
||||
$this->restoredToDefault = true;
|
||||
}
|
||||
|
||||
public function currentDatabaseUrl(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user