feat: Infrastructure multi-tenant avec isolation par sous-domaine

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.
This commit is contained in:
2026-01-30 23:34:10 +01:00
parent 6da5996340
commit 1fd256346a
71 changed files with 14390 additions and 37 deletions

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(InMemoryTenantRegistry::class)]
final class InMemoryTenantRegistryTest extends TestCase
{
#[Test]
public function itReturnsConfigByTenantId(): void
{
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
$config = new TenantConfig(
tenantId: $tenantId,
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$registry = new InMemoryTenantRegistry([$config]);
$result = $registry->getConfig($tenantId);
self::assertSame($config, $result);
}
#[Test]
public function itReturnsConfigBySubdomain(): void
{
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
$config = new TenantConfig(
tenantId: $tenantId,
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$registry = new InMemoryTenantRegistry([$config]);
$result = $registry->getBySubdomain('ecole-alpha');
self::assertSame($config, $result);
}
#[Test]
public function itThrowsExceptionForUnknownTenantId(): void
{
$registry = new InMemoryTenantRegistry([]);
$this->expectException(TenantNotFoundException::class);
$registry->getConfig(TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'));
}
#[Test]
public function itThrowsExceptionForUnknownSubdomain(): void
{
$registry = new InMemoryTenantRegistry([]);
$this->expectException(TenantNotFoundException::class);
$registry->getBySubdomain('ecole-inexistant');
}
#[Test]
public function itChecksIfTenantExists(): void
{
$config = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$registry = new InMemoryTenantRegistry([$config]);
self::assertTrue($registry->exists('ecole-alpha'));
self::assertFalse($registry->exists('ecole-inexistant'));
}
#[Test]
public function itSupportsMultipleTenants(): void
{
$configAlpha = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$configBeta = new TenantConfig(
tenantId: TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901'),
subdomain: 'ecole-beta',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_beta',
);
$registry = new InMemoryTenantRegistry([$configAlpha, $configBeta]);
self::assertSame($configAlpha, $registry->getBySubdomain('ecole-alpha'));
self::assertSame($configBeta, $registry->getBySubdomain('ecole-beta'));
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantId;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
#[CoversClass(TenantConfig::class)]
final class TenantConfigTest extends TestCase
{
#[Test]
public function itCanBeCreatedWithRequiredProperties(): void
{
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
$subdomain = 'ecole-alpha';
$databaseUrl = 'postgresql://user:pass@localhost:5432/classeo_alpha';
$config = new TenantConfig(
tenantId: $tenantId,
subdomain: $subdomain,
databaseUrl: $databaseUrl,
);
self::assertTrue($tenantId->equals($config->tenantId));
self::assertSame($subdomain, $config->subdomain);
self::assertSame($databaseUrl, $config->databaseUrl);
}
#[Test]
public function itIsImmutable(): void
{
$config = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$reflection = new ReflectionClass($config);
self::assertTrue($reflection->isReadonly());
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantNotSetException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(TenantContext::class)]
final class TenantContextTest extends TestCase
{
#[Test]
public function itCanSetAndRetrieveCurrentTenant(): void
{
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
$config = new TenantConfig(
tenantId: $tenantId,
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$context = new TenantContext();
$context->setCurrentTenant($config);
self::assertTrue($tenantId->equals($context->getCurrentTenantId()));
self::assertSame($config, $context->getCurrentTenantConfig());
}
#[Test]
public function itThrowsExceptionWhenNoTenantIsSet(): void
{
$context = new TenantContext();
$this->expectException(TenantNotSetException::class);
$context->getCurrentTenantId();
}
#[Test]
public function itThrowsExceptionWhenGettingConfigWithNoTenantSet(): void
{
$context = new TenantContext();
$this->expectException(TenantNotSetException::class);
$context->getCurrentTenantConfig();
}
#[Test]
public function itCanCheckIfTenantIsSet(): void
{
$context = new TenantContext();
self::assertFalse($context->hasTenant());
$config = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$context->setCurrentTenant($config);
self::assertTrue($context->hasTenant());
}
#[Test]
public function itCanClearTenant(): void
{
$config = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$context = new TenantContext();
$context->setCurrentTenant($config);
self::assertTrue($context->hasTenant());
$context->clear();
self::assertFalse($context->hasTenant());
}
}

View File

@@ -0,0 +1,213 @@
<?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;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
use App\Shared\Infrastructure\Tenant\TenantId;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
#[CoversClass(TenantId::class)]
final class TenantIdTest extends TestCase
{
#[Test]
public function itCanBeGeneratedWithRandomUuid(): void
{
$tenantId = TenantId::generate();
self::assertTrue(Uuid::isValid((string) $tenantId));
}
#[Test]
public function itCanBeCreatedFromString(): void
{
$uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
$tenantId = TenantId::fromString($uuid);
self::assertSame($uuid, (string) $tenantId);
}
#[Test]
public function twoTenantIdsWithSameValueAreEqual(): void
{
$uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
$tenantId1 = TenantId::fromString($uuid);
$tenantId2 = TenantId::fromString($uuid);
self::assertTrue($tenantId1->equals($tenantId2));
}
#[Test]
public function twoTenantIdsWithDifferentValuesAreNotEqual(): void
{
$tenantId1 = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
$tenantId2 = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
self::assertFalse($tenantId1->equals($tenantId2));
}
#[Test]
public function itCanBeConvertedToString(): void
{
$uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
$tenantId = TenantId::fromString($uuid);
self::assertSame($uuid, (string) $tenantId);
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantMiddleware;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
#[CoversClass(TenantMiddleware::class)]
final class TenantMiddlewareTest extends TestCase
{
private TenantResolver $resolver;
private TenantContext $context;
protected function setUp(): void
{
$this->resolver = $this->createMock(TenantResolver::class);
$this->context = new TenantContext();
}
#[Test]
public function itSetsTenantContextForValidTenant(): void
{
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
$config = new TenantConfig(
tenantId: $tenantId,
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$this->resolver->method('resolve')
->with('ecole-alpha.classeo.local')
->willReturn($config);
$middleware = new TenantMiddleware($this->resolver, $this->context);
$request = Request::create('https://ecole-alpha.classeo.local/api/test');
$event = $this->createRequestEvent($request);
$middleware->onKernelRequest($event);
self::assertTrue($this->context->hasTenant());
self::assertTrue($tenantId->equals($this->context->getCurrentTenantId()));
}
#[Test]
public function itReturns404ForNonExistentTenant(): void
{
$this->resolver->method('resolve')
->with('ecole-inexistant.classeo.local')
->willThrowException(TenantNotFoundException::withSubdomain('ecole-inexistant'));
$middleware = new TenantMiddleware($this->resolver, $this->context);
$request = Request::create('https://ecole-inexistant.classeo.local/api/test');
$event = $this->createRequestEvent($request);
$middleware->onKernelRequest($event);
self::assertTrue($event->hasResponse());
self::assertSame(Response::HTTP_NOT_FOUND, $event->getResponse()?->getStatusCode());
}
#[Test]
public function itReturnsGenericErrorMessageFor404(): void
{
$this->resolver->method('resolve')
->willThrowException(TenantNotFoundException::withSubdomain('test'));
$middleware = new TenantMiddleware($this->resolver, $this->context);
$request = Request::create('https://test.classeo.local/api/test');
$event = $this->createRequestEvent($request);
$middleware->onKernelRequest($event);
$response = $event->getResponse();
self::assertNotNull($response);
$content = json_decode((string) $response->getContent(), true);
self::assertSame('Resource not found', $content['message']);
self::assertArrayNotHasKey('subdomain', $content);
}
#[Test]
public function itClearsTenantContextOnTerminate(): void
{
$config = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$this->context->setCurrentTenant($config);
self::assertTrue($this->context->hasTenant());
$middleware = new TenantMiddleware($this->resolver, $this->context);
$middleware->onKernelTerminate();
self::assertFalse($this->context->hasTenant());
}
#[Test]
public function itRegistersCorrectEvents(): void
{
$events = TenantMiddleware::getSubscribedEvents();
self::assertArrayHasKey(KernelEvents::REQUEST, $events);
self::assertArrayHasKey(KernelEvents::TERMINATE, $events);
// Request listener should have high priority to run early
$requestConfig = $events[KernelEvents::REQUEST];
self::assertIsArray($requestConfig);
self::assertSame('onKernelRequest', $requestConfig[0]);
self::assertGreaterThan(0, $requestConfig[1]); // High priority
}
private function createRequestEvent(Request $request): RequestEvent
{
$kernel = $this->createMock(HttpKernelInterface::class);
return new RequestEvent(
$kernel,
$request,
HttpKernelInterface::MAIN_REQUEST
);
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
#[CoversClass(TenantResolver::class)]
final class TenantResolverTest extends TestCase
{
private TenantRegistry $registry;
protected function setUp(): void
{
$this->registry = $this->createMock(TenantRegistry::class);
}
#[Test]
public function itExtractsSubdomainFromHost(): void
{
$resolver = new TenantResolver($this->registry, 'classeo.local');
$subdomain = $resolver->extractSubdomain('ecole-alpha.classeo.local');
self::assertSame('ecole-alpha', $subdomain);
}
#[Test]
public function itExtractsSubdomainFromHostWithPort(): void
{
$resolver = new TenantResolver($this->registry, 'classeo.local');
$subdomain = $resolver->extractSubdomain('ecole-alpha.classeo.local:8080');
self::assertSame('ecole-alpha', $subdomain);
}
#[Test]
public function itReturnsNullForMainDomainWithoutSubdomain(): void
{
$resolver = new TenantResolver($this->registry, 'classeo.local');
$subdomain = $resolver->extractSubdomain('classeo.local');
self::assertNull($subdomain);
}
#[Test]
public function itReturnsNullForWwwSubdomain(): void
{
$resolver = new TenantResolver($this->registry, 'classeo.local');
$subdomain = $resolver->extractSubdomain('www.classeo.local');
self::assertNull($subdomain);
}
#[Test]
public function itReturnsNullForApiSubdomain(): void
{
$resolver = new TenantResolver($this->registry, 'classeo.local');
$subdomain = $resolver->extractSubdomain('api.classeo.local');
self::assertNull($subdomain);
}
#[Test]
public function itResolvesValidTenantFromHost(): void
{
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
$config = new TenantConfig(
tenantId: $tenantId,
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
);
$this->registry->method('getBySubdomain')
->with('ecole-alpha')
->willReturn($config);
$resolver = new TenantResolver($this->registry, 'classeo.local');
$resolved = $resolver->resolve('ecole-alpha.classeo.local');
self::assertTrue($tenantId->equals($resolved->tenantId));
}
#[Test]
public function itThrowsExceptionForNonExistentTenant(): void
{
$this->registry->method('getBySubdomain')
->with('ecole-inexistant')
->willThrowException(TenantNotFoundException::withSubdomain('ecole-inexistant'));
$resolver = new TenantResolver($this->registry, 'classeo.local');
$this->expectException(TenantNotFoundException::class);
$resolver->resolve('ecole-inexistant.classeo.local');
}
#[Test]
public function itThrowsExceptionWhenNoSubdomainInHost(): void
{
$resolver = new TenantResolver($this->registry, 'classeo.local');
$this->expectException(TenantNotFoundException::class);
$resolver->resolve('classeo.local');
}
#[Test]
#[DataProvider('reservedSubdomainsProvider')]
public function itRejectsReservedSubdomains(string $subdomain): void
{
$resolver = new TenantResolver($this->registry, 'classeo.local');
$this->expectException(TenantNotFoundException::class);
$resolver->resolve("{$subdomain}.classeo.local");
}
/**
* @return iterable<string, array{string}>
*/
public static function reservedSubdomainsProvider(): iterable
{
yield 'www' => ['www'];
yield 'api' => ['api'];
yield 'admin' => ['admin'];
yield 'static' => ['static'];
yield 'cdn' => ['cdn'];
yield 'mail' => ['mail'];
}
}