Une application SaaS éducative nécessite une séparation stricte des données entre établissements scolaires. L'architecture multi-tenant par sous-domaine (ecole-alpha.classeo.local) permet cette isolation tout en utilisant une base de code unique. Le choix d'une résolution basée sur les sous-domaines plutôt que sur des headers ou tokens facilite le routage au niveau infrastructure (reverse proxy) et offre une UX plus naturelle où chaque école accède à "son" URL dédiée.
214 lines
7.1 KiB
PHP
214 lines
7.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
|
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
|
use App\Shared\Infrastructure\Tenant\TenantEntityManagerFactory;
|
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
|
use DateTimeImmutable;
|
|
use Doctrine\ORM\Configuration;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Unit tests for TenantEntityManagerFactory.
|
|
*
|
|
* Note: These tests use SQLite in-memory databases which requires proper
|
|
* Doctrine ORM configuration. For full integration testing with PostgreSQL,
|
|
* see the Integration tests.
|
|
*/
|
|
#[CoversClass(TenantEntityManagerFactory::class)]
|
|
final class TenantEntityManagerFactoryTest extends TestCase
|
|
{
|
|
private TenantRegistry $registry;
|
|
private Clock $clock;
|
|
private Configuration $ormConfiguration;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->registry = $this->createMock(TenantRegistry::class);
|
|
$this->clock = $this->createMock(Clock::class);
|
|
$this->ormConfiguration = $this->createOrmConfiguration();
|
|
}
|
|
|
|
#[Test]
|
|
public function itReturnsEntityManagerForTenant(): void
|
|
{
|
|
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
$config = new TenantConfig(
|
|
tenantId: $tenantId,
|
|
subdomain: 'ecole-alpha',
|
|
databaseUrl: 'sqlite:///:memory:',
|
|
);
|
|
|
|
$this->registry->method('getConfig')->with($tenantId)->willReturn($config);
|
|
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
|
|
|
$factory = new TenantEntityManagerFactory(
|
|
$this->registry,
|
|
$this->clock,
|
|
$this->ormConfiguration,
|
|
);
|
|
|
|
$entityManager = $factory->getForTenant($tenantId);
|
|
|
|
self::assertInstanceOf(EntityManagerInterface::class, $entityManager);
|
|
}
|
|
|
|
#[Test]
|
|
public function itReturnsSameEntityManagerForSameTenant(): void
|
|
{
|
|
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
$config = new TenantConfig(
|
|
tenantId: $tenantId,
|
|
subdomain: 'ecole-alpha',
|
|
databaseUrl: 'sqlite:///:memory:',
|
|
);
|
|
|
|
$this->registry->method('getConfig')->with($tenantId)->willReturn($config);
|
|
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
|
|
|
$factory = new TenantEntityManagerFactory(
|
|
$this->registry,
|
|
$this->clock,
|
|
$this->ormConfiguration,
|
|
);
|
|
|
|
$em1 = $factory->getForTenant($tenantId);
|
|
$em2 = $factory->getForTenant($tenantId);
|
|
|
|
self::assertSame($em1, $em2);
|
|
}
|
|
|
|
#[Test]
|
|
public function itReturnsDifferentEntityManagersForDifferentTenants(): void
|
|
{
|
|
$tenantIdAlpha = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
$tenantIdBeta = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
|
|
|
$configAlpha = new TenantConfig(
|
|
tenantId: $tenantIdAlpha,
|
|
subdomain: 'ecole-alpha',
|
|
databaseUrl: 'sqlite:///:memory:',
|
|
);
|
|
$configBeta = new TenantConfig(
|
|
tenantId: $tenantIdBeta,
|
|
subdomain: 'ecole-beta',
|
|
databaseUrl: 'sqlite:///:memory:',
|
|
);
|
|
|
|
$this->registry->method('getConfig')->willReturnMap([
|
|
[$tenantIdAlpha, $configAlpha],
|
|
[$tenantIdBeta, $configBeta],
|
|
]);
|
|
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
|
|
|
$factory = new TenantEntityManagerFactory(
|
|
$this->registry,
|
|
$this->clock,
|
|
$this->ormConfiguration,
|
|
);
|
|
|
|
$emAlpha = $factory->getForTenant($tenantIdAlpha);
|
|
$emBeta = $factory->getForTenant($tenantIdBeta);
|
|
|
|
self::assertNotSame($emAlpha, $emBeta);
|
|
}
|
|
|
|
#[Test]
|
|
public function itReturnsCorrectPoolSize(): void
|
|
{
|
|
$tenantId1 = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
$tenantId2 = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
|
|
|
$config1 = new TenantConfig(
|
|
tenantId: $tenantId1,
|
|
subdomain: 'ecole-alpha',
|
|
databaseUrl: 'sqlite:///:memory:',
|
|
);
|
|
$config2 = new TenantConfig(
|
|
tenantId: $tenantId2,
|
|
subdomain: 'ecole-beta',
|
|
databaseUrl: 'sqlite:///:memory:',
|
|
);
|
|
|
|
$this->registry->method('getConfig')->willReturnMap([
|
|
[$tenantId1, $config1],
|
|
[$tenantId2, $config2],
|
|
]);
|
|
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
|
|
|
$factory = new TenantEntityManagerFactory(
|
|
$this->registry,
|
|
$this->clock,
|
|
$this->ormConfiguration,
|
|
);
|
|
|
|
self::assertSame(0, $factory->getPoolSize());
|
|
|
|
$factory->getForTenant($tenantId1);
|
|
self::assertSame(1, $factory->getPoolSize());
|
|
|
|
$factory->getForTenant($tenantId2);
|
|
self::assertSame(2, $factory->getPoolSize());
|
|
|
|
// Accessing same tenant shouldn't increase pool size
|
|
$factory->getForTenant($tenantId1);
|
|
self::assertSame(2, $factory->getPoolSize());
|
|
}
|
|
|
|
#[Test]
|
|
public function itEvictsIdleConnectionsAfterTimeout(): void
|
|
{
|
|
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
$config = new TenantConfig(
|
|
tenantId: $tenantId,
|
|
subdomain: 'ecole-alpha',
|
|
databaseUrl: 'sqlite:///:memory:',
|
|
);
|
|
|
|
$this->registry->method('getConfig')->with($tenantId)->willReturn($config);
|
|
|
|
$initialTime = new DateTimeImmutable('2026-01-30 10:00:00');
|
|
$afterTimeout = new DateTimeImmutable('2026-01-30 10:06:00'); // 6 minutes later
|
|
|
|
$this->clock->method('now')->willReturnOnConsecutiveCalls(
|
|
$initialTime, // First call - eviction check
|
|
$initialTime, // Store lastUsed
|
|
$afterTimeout, // Second call - eviction check (finds idle)
|
|
$afterTimeout, // Store lastUsed for new manager
|
|
);
|
|
|
|
$factory = new TenantEntityManagerFactory(
|
|
$this->registry,
|
|
$this->clock,
|
|
$this->ormConfiguration,
|
|
);
|
|
|
|
$em1 = $factory->getForTenant($tenantId);
|
|
$em2 = $factory->getForTenant($tenantId);
|
|
|
|
// Due to idle eviction, we should have a new entity manager
|
|
self::assertNotSame($em1, $em2);
|
|
}
|
|
|
|
private function createOrmConfiguration(): Configuration
|
|
{
|
|
$config = new Configuration();
|
|
$config->setProxyDir(sys_get_temp_dir() . '/doctrine_proxies_' . uniqid());
|
|
$config->setProxyNamespace('DoctrineProxies');
|
|
$config->setAutoGenerateProxyClasses(true);
|
|
$config->setMetadataDriverImpl(new AttributeDriver([]));
|
|
$config->enableNativeLazyObjects(true);
|
|
|
|
return $config;
|
|
}
|
|
}
|