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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user