fix(tenant): route runtime traffic to tenant databases
Wire Doctrine's default connection to the tenant database resolved from the subdomain for HTTP requests and tenant-scoped Messenger messages while keeping master-only services on the master connection. This removes the production inconsistency where demo data, migrations and tenant commands used the tenant database but the web runtime still read from master.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Infrastructure\Messenger\TenantDatabaseMiddleware;
|
||||
use App\Shared\Infrastructure\Messenger\TenantMessageTenantIdResolver;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
|
||||
#[CoversClass(TenantDatabaseMiddleware::class)]
|
||||
final class TenantDatabaseMiddlewareTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itSwitchesToTheMessageTenantDatabaseAndRestoresThePreviousOne(): void
|
||||
{
|
||||
$tenantConfig = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://tenant:secret@db:5432/classeo_tenant_alpha?serverVersion=18&charset=utf8',
|
||||
);
|
||||
$message = new readonly class('a1b2c3d4-e5f6-7890-abcd-ef1234567890') {
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
};
|
||||
|
||||
$registry = $this->createMock(TenantRegistry::class);
|
||||
$registry->expects(self::once())
|
||||
->method('getConfig')
|
||||
->with(self::callback(static fn (TenantId $tenantId): bool => (string) $tenantId === 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'))
|
||||
->willReturn($tenantConfig);
|
||||
|
||||
$databaseSwitcher = $this->createMock(TenantDatabaseSwitcher::class);
|
||||
$databaseSwitcher->expects(self::once())
|
||||
->method('currentDatabaseUrl')
|
||||
->willReturn('postgresql://tenant:secret@db:5432/classeo_tenant_previous?serverVersion=18&charset=utf8');
|
||||
$databaseSwitcher->expects(self::exactly(2))
|
||||
->method('useTenantDatabase')
|
||||
->with(
|
||||
self::logicalOr(
|
||||
self::equalTo($tenantConfig->databaseUrl),
|
||||
self::equalTo('postgresql://tenant:secret@db:5432/classeo_tenant_previous?serverVersion=18&charset=utf8'),
|
||||
),
|
||||
);
|
||||
|
||||
$middleware = new TenantDatabaseMiddleware(
|
||||
$registry,
|
||||
$databaseSwitcher,
|
||||
new TenantMessageTenantIdResolver(),
|
||||
);
|
||||
|
||||
$envelope = new Envelope($message);
|
||||
$result = $middleware->handle($envelope, new TestStack(
|
||||
new class implements MiddlewareInterface {
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
return $envelope;
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
self::assertSame($envelope, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itLeavesTheCurrentConnectionUntouchedWhenTheMessageHasNoTenantId(): void
|
||||
{
|
||||
$registry = $this->createMock(TenantRegistry::class);
|
||||
$registry->expects(self::never())->method('getConfig');
|
||||
|
||||
$databaseSwitcher = $this->createMock(TenantDatabaseSwitcher::class);
|
||||
$databaseSwitcher->expects(self::never())->method('currentDatabaseUrl');
|
||||
$databaseSwitcher->expects(self::never())->method('useTenantDatabase');
|
||||
$databaseSwitcher->expects(self::never())->method('useDefaultDatabase');
|
||||
|
||||
$middleware = new TenantDatabaseMiddleware(
|
||||
$registry,
|
||||
$databaseSwitcher,
|
||||
new TenantMessageTenantIdResolver(),
|
||||
);
|
||||
|
||||
$message = new readonly class('batch-1') {
|
||||
public function __construct(
|
||||
public string $batchId,
|
||||
) {
|
||||
}
|
||||
};
|
||||
|
||||
$envelope = new Envelope($message);
|
||||
$result = $middleware->handle($envelope, new TestStack(
|
||||
new class implements MiddlewareInterface {
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
return $envelope;
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
self::assertSame($envelope, $result);
|
||||
}
|
||||
}
|
||||
|
||||
final readonly class TestStack implements StackInterface
|
||||
{
|
||||
public function __construct(
|
||||
private MiddlewareInterface $next,
|
||||
) {
|
||||
}
|
||||
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return $this->next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Messenger\TenantMessageTenantIdResolver;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(TenantMessageTenantIdResolver::class)]
|
||||
final class TenantMessageTenantIdResolverTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itReadsTenantIdsFromStringProperties(): void
|
||||
{
|
||||
$resolver = new TenantMessageTenantIdResolver();
|
||||
$message = new readonly class('a1b2c3d4-e5f6-7890-abcd-ef1234567890') {
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
) {
|
||||
}
|
||||
};
|
||||
|
||||
self::assertSame('a1b2c3d4-e5f6-7890-abcd-ef1234567890', $resolver->resolve($message));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReadsTenantIdsFromValueObjects(): void
|
||||
{
|
||||
$resolver = new TenantMessageTenantIdResolver();
|
||||
$message = new readonly class(TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901')) {
|
||||
public function __construct(
|
||||
public TenantId $tenantId,
|
||||
) {
|
||||
}
|
||||
};
|
||||
|
||||
self::assertSame('b2c3d4e5-f6a7-8901-bcde-f12345678901', $resolver->resolve($message));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullWhenTheMessageIsNotTenantScoped(): void
|
||||
{
|
||||
$resolver = new TenantMessageTenantIdResolver();
|
||||
$message = new readonly class('') {
|
||||
public function __construct(
|
||||
public string $batchId,
|
||||
) {
|
||||
}
|
||||
};
|
||||
|
||||
self::assertNull($resolver->resolve($message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Shared\Infrastructure\Persistence\Doctrine\TenantAwareConnection;
|
||||
use Doctrine\DBAL\Driver;
|
||||
use Doctrine\DBAL\Driver\API\ExceptionConverter;
|
||||
use Doctrine\DBAL\Driver\Connection as DriverConnection;
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
#[CoversClass(TenantAwareConnection::class)]
|
||||
final class TenantAwareConnectionTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itSwitchesBetweenDefaultAndTenantConnectionParams(): void
|
||||
{
|
||||
$driverConnection = $this->createMock(DriverConnection::class);
|
||||
$driverConnection->method('getServerVersion')->willReturn('18.1');
|
||||
|
||||
$capturedParams = [];
|
||||
$driver = $this->createDriver($driverConnection, $capturedParams);
|
||||
|
||||
$connection = new TenantAwareConnection(
|
||||
[
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'db',
|
||||
'port' => 5432,
|
||||
'user' => 'master',
|
||||
'password' => 'secret',
|
||||
'dbname' => 'classeo_master',
|
||||
'serverVersion' => '18',
|
||||
],
|
||||
$driver,
|
||||
);
|
||||
|
||||
self::assertSame('18.1', $connection->getServerVersion());
|
||||
self::assertSame('classeo_master', $capturedParams[0]['dbname']);
|
||||
self::assertNull($connection->currentDatabaseUrl());
|
||||
|
||||
$tenantDatabaseUrl = 'postgresql://tenant:tenantpass@tenant-db:5432/classeo_tenant_alpha?serverVersion=18&charset=utf8';
|
||||
$connection->useTenantDatabase($tenantDatabaseUrl);
|
||||
|
||||
self::assertSame('18.1', $connection->getServerVersion());
|
||||
self::assertSame('classeo_tenant_alpha', $capturedParams[1]['dbname']);
|
||||
self::assertSame('tenant-db', $capturedParams[1]['host']);
|
||||
self::assertSame('tenant', $capturedParams[1]['user']);
|
||||
self::assertSame($tenantDatabaseUrl, $connection->currentDatabaseUrl());
|
||||
|
||||
$connection->useDefaultDatabase();
|
||||
|
||||
self::assertSame('18.1', $connection->getServerVersion());
|
||||
self::assertSame('classeo_master', $capturedParams[2]['dbname']);
|
||||
self::assertNull($connection->currentDatabaseUrl());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsDatabaseSwitchesWhileATransactionIsOpen(): void
|
||||
{
|
||||
$driverConnection = $this->createMock(DriverConnection::class);
|
||||
$driverConnection->method('getServerVersion')->willReturn('18.1');
|
||||
|
||||
$capturedParams = [];
|
||||
$driver = $this->createDriver($driverConnection, $capturedParams);
|
||||
$connection = new TenantAwareConnection(
|
||||
[
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'db',
|
||||
'port' => 5432,
|
||||
'user' => 'master',
|
||||
'password' => 'secret',
|
||||
'dbname' => 'classeo_master',
|
||||
'serverVersion' => '18',
|
||||
],
|
||||
$driver,
|
||||
);
|
||||
|
||||
$connection->beginTransaction();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Cannot switch database while a transaction is active.');
|
||||
|
||||
$connection->useTenantDatabase('postgresql://tenant:tenantpass@tenant-db:5432/classeo_tenant_alpha?serverVersion=18&charset=utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $capturedParams
|
||||
*/
|
||||
private function createDriver(DriverConnection $driverConnection, array &$capturedParams): Driver
|
||||
{
|
||||
$driver = $this->createMock(Driver::class);
|
||||
$driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
|
||||
$driver->method('getExceptionConverter')->willReturn($this->createMock(ExceptionConverter::class));
|
||||
$driver->method('connect')->willReturnCallback(
|
||||
static function (array $params) use (&$capturedParams, $driverConnection): DriverConnection {
|
||||
$capturedParams[] = $params;
|
||||
|
||||
return $driverConnection;
|
||||
},
|
||||
);
|
||||
|
||||
return $driver;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantDatabaseRequestSubscriber;
|
||||
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
#[CoversClass(TenantDatabaseRequestSubscriber::class)]
|
||||
final class TenantDatabaseRequestSubscriberTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itUsesTheTenantDatabaseResolvedByTheMiddleware(): void
|
||||
{
|
||||
$tenantConfig = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://tenant:secret@db:5432/classeo_tenant_alpha?serverVersion=18&charset=utf8',
|
||||
);
|
||||
|
||||
$databaseSwitcher = $this->createMock(TenantDatabaseSwitcher::class);
|
||||
$databaseSwitcher->expects(self::once())
|
||||
->method('useTenantDatabase')
|
||||
->with($tenantConfig->databaseUrl);
|
||||
|
||||
$subscriber = new TenantDatabaseRequestSubscriber($databaseSwitcher);
|
||||
$request = Request::create('https://ecole-alpha.classeo.local/api/users');
|
||||
$request->attributes->set('_tenant', $tenantConfig);
|
||||
|
||||
$subscriber->onKernelRequest($this->createRequestEvent($request));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFallsBackToTheDefaultDatabaseWhenNoTenantWasResolved(): void
|
||||
{
|
||||
$databaseSwitcher = $this->createMock(TenantDatabaseSwitcher::class);
|
||||
$databaseSwitcher->expects(self::once())
|
||||
->method('useDefaultDatabase');
|
||||
|
||||
$subscriber = new TenantDatabaseRequestSubscriber($databaseSwitcher);
|
||||
$request = Request::create('https://classeo.local/api/docs');
|
||||
|
||||
$subscriber->onKernelRequest($this->createRequestEvent($request));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResetsTheConnectionAfterTheRequest(): void
|
||||
{
|
||||
$databaseSwitcher = $this->createMock(TenantDatabaseSwitcher::class);
|
||||
$databaseSwitcher->expects(self::once())
|
||||
->method('useDefaultDatabase');
|
||||
|
||||
$subscriber = new TenantDatabaseRequestSubscriber($databaseSwitcher);
|
||||
$subscriber->onKernelTerminate();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRegistersItselfRightAfterTenantResolution(): void
|
||||
{
|
||||
$events = TenantDatabaseRequestSubscriber::getSubscribedEvents();
|
||||
|
||||
self::assertSame(['onKernelRequest', 255], $events[KernelEvents::REQUEST]);
|
||||
self::assertSame('onKernelTerminate', $events[KernelEvents::TERMINATE]);
|
||||
}
|
||||
|
||||
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