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.
146 lines
4.2 KiB
PHP
146 lines
4.2 KiB
PHP
<?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'];
|
|
}
|
|
}
|