fix(tenant): route runtime traffic to tenant databases
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-03-10 23:38:26 +01:00
parent 0f3e57c6e6
commit 8c70ed1324
16 changed files with 840 additions and 4 deletions

View File

@@ -1,7 +1,14 @@
doctrine: doctrine:
dbal: dbal:
url: '%env(resolve:DATABASE_URL)%' default_connection: default
profiling_collect_backtrace: '%kernel.debug%' connections:
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
wrapper_class: App\Shared\Infrastructure\Persistence\Doctrine\TenantAwareConnection
master:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
orm: orm:
validate_xml_mapping: true validate_xml_mapping: true
@@ -45,7 +52,11 @@ doctrine:
when@test: when@test:
doctrine: doctrine:
dbal: dbal:
dbname_suffix: '_test%env(default::TEST_TOKEN)%' connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
master:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod: when@prod:
doctrine: doctrine:

View File

@@ -11,6 +11,7 @@ framework:
middleware: middleware:
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware - App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware - App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
- App\Shared\Infrastructure\Messenger\TenantDatabaseMiddleware
- doctrine_transaction - doctrine_transaction
query.bus: query.bus:
@@ -18,6 +19,7 @@ framework:
middleware: middleware:
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware - App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware - App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
- App\Shared\Infrastructure\Messenger\TenantDatabaseMiddleware
event.bus: event.bus:
default_middleware: default_middleware:
@@ -25,6 +27,7 @@ framework:
middleware: middleware:
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware - App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware - App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
- App\Shared\Infrastructure\Messenger\TenantDatabaseMiddleware
- App\Administration\Infrastructure\Middleware\PaginatedCacheInvalidationMiddleware - App\Administration\Infrastructure\Middleware\PaginatedCacheInvalidationMiddleware
- App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware - App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware

View File

@@ -52,6 +52,12 @@ services:
arguments: arguments:
$baseDomain: '%tenant.base_domain%' $baseDomain: '%tenant.base_domain%'
App\Shared\Infrastructure\Persistence\Doctrine\TenantAwareConnection:
alias: doctrine.dbal.default_connection
App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher:
alias: doctrine.dbal.default_connection
# TenantRegistry est configuré par environnement : # TenantRegistry est configuré par environnement :
# - dev: config/packages/dev/tenant.yaml (tenants de test) # - dev: config/packages/dev/tenant.yaml (tenants de test)
# - prod: à configurer via admin ou env vars # - prod: à configurer via admin ou env vars
@@ -116,8 +122,21 @@ services:
App\Shared\Infrastructure\Audit\AuditLogger: App\Shared\Infrastructure\Audit\AuditLogger:
arguments: arguments:
$connection: '@doctrine.dbal.master_connection'
$appSecret: '%env(APP_SECRET)%' $appSecret: '%env(APP_SECRET)%'
App\Shared\Infrastructure\Audit\AuditLogRepository:
arguments:
$connection: '@doctrine.dbal.master_connection'
App\Shared\Infrastructure\Console\ArchiveAuditLogsCommand:
arguments:
$connection: '@doctrine.dbal.master_connection'
App\Shared\Infrastructure\Console\ReviewFailedMessagesCommand:
arguments:
$connection: '@doctrine.dbal.master_connection'
# Audit log handlers (use AuditLogger to write to database) # Audit log handlers (use AuditLogger to write to database)
App\Shared\Infrastructure\Audit\Handler\AuditAuthenticationHandler: App\Shared\Infrastructure\Audit\Handler\AuditAuthenticationHandler:
arguments: arguments:
@@ -132,6 +151,18 @@ services:
arguments: arguments:
$userRepository: '@App\Administration\Domain\Repository\UserRepository' $userRepository: '@App\Administration\Domain\Repository\UserRepository'
App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineSuperAdminRepository:
arguments:
$connection: '@doctrine.dbal.master_connection'
App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository:
arguments:
$connection: '@doctrine.dbal.master_connection'
App\SuperAdmin\Application\Query\GetEstablishmentsMetrics\GetEstablishmentsMetricsHandler:
arguments:
$connection: '@doctrine.dbal.master_connection'
# Refresh Token Repository # Refresh Token Repository
App\Administration\Domain\Repository\RefreshTokenRepository: App\Administration\Domain\Repository\RefreshTokenRepository:
alias: App\Administration\Infrastructure\Persistence\Redis\RedisRefreshTokenRepository alias: App\Administration\Infrastructure\Persistence\Redis\RedisRefreshTokenRepository
@@ -346,6 +377,7 @@ services:
# Infrastructure Health Checker - shared service for health checks (DRY) # Infrastructure Health Checker - shared service for health checks (DRY)
App\Shared\Infrastructure\Monitoring\InfrastructureHealthChecker: App\Shared\Infrastructure\Monitoring\InfrastructureHealthChecker:
arguments: arguments:
$connection: '@doctrine.dbal.master_connection'
$redisUrl: '%env(REDIS_URL)%' $redisUrl: '%env(REDIS_URL)%'
# Interface alias for InfrastructureHealthChecker (allows testing with stubs) # Interface alias for InfrastructureHealthChecker (allows testing with stubs)

View File

@@ -12,9 +12,11 @@ use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository; use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock; use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantNotFoundException; use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
use App\Shared\Infrastructure\Tenant\TenantRegistry; use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function getenv;
use function sprintf; use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -23,6 +25,8 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Process\Process;
/** /**
* Creates an already-activated test user for E2E login tests. * Creates an already-activated test user for E2E login tests.
@@ -41,6 +45,8 @@ final class CreateTestUserCommand extends Command
private readonly PasswordHasher $passwordHasher, private readonly PasswordHasher $passwordHasher,
private readonly TenantRegistry $tenantRegistry, private readonly TenantRegistry $tenantRegistry,
private readonly Clock $clock, private readonly Clock $clock,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -51,8 +57,11 @@ final class CreateTestUserCommand extends Command
->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'e2e-login@example.com') ->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'e2e-login@example.com')
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password (plain text)', 'TestPassword123') ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password (plain text)', 'TestPassword123')
->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT') ->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT')
->addOption('firstName', null, InputOption::VALUE_OPTIONAL, 'First name', '')
->addOption('lastName', null, InputOption::VALUE_OPTIONAL, 'Last name', '')
->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test') ->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test')
->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain (ecole-alpha, ecole-beta)', 'ecole-alpha'); ->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain (ecole-alpha, ecole-beta)', 'ecole-alpha')
->addOption('internal-run', null, InputOption::VALUE_NONE, 'Internal option to create the test user inside the tenant database');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
@@ -66,10 +75,15 @@ final class CreateTestUserCommand extends Command
/** @var string $roleOption */ /** @var string $roleOption */
$roleOption = $input->getOption('role'); $roleOption = $input->getOption('role');
$roleInput = strtoupper($roleOption); $roleInput = strtoupper($roleOption);
/** @var string $firstName */
$firstName = $input->getOption('firstName');
/** @var string $lastName */
$lastName = $input->getOption('lastName');
/** @var string $schoolName */ /** @var string $schoolName */
$schoolName = $input->getOption('school'); $schoolName = $input->getOption('school');
/** @var string $tenantSubdomain */ /** @var string $tenantSubdomain */
$tenantSubdomain = $input->getOption('tenant'); $tenantSubdomain = $input->getOption('tenant');
$internalRun = $input->getOption('internal-run');
// Convert short role name to full Symfony role format // Convert short role name to full Symfony role format
$roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput; $roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput;
@@ -104,6 +118,19 @@ final class CreateTestUserCommand extends Command
return Command::FAILURE; return Command::FAILURE;
} }
if (!$internalRun) {
return $this->relaunchAgainstTenantDatabase(
email: $email,
password: $password,
roleName: $roleName,
firstName: $firstName,
lastName: $lastName,
schoolName: $schoolName,
tenantConfig: $tenantConfig,
io: $io,
);
}
$now = $this->clock->now(); $now = $this->clock->now();
// Check if user already exists // Check if user already exists
@@ -140,6 +167,8 @@ final class CreateTestUserCommand extends Command
hashedPassword: $hashedPassword, hashedPassword: $hashedPassword,
activatedAt: $now, activatedAt: $now,
consentementParental: null, consentementParental: null,
firstName: $firstName,
lastName: $lastName,
); );
$this->userRepository->save($user); $this->userRepository->save($user);
@@ -161,4 +190,52 @@ final class CreateTestUserCommand extends Command
return Command::SUCCESS; return Command::SUCCESS;
} }
private function relaunchAgainstTenantDatabase(
string $email,
string $password,
string $roleName,
string $firstName,
string $lastName,
string $schoolName,
TenantConfig $tenantConfig,
SymfonyStyle $io,
): int {
$process = new Process(
command: [
'php',
'bin/console',
'app:dev:create-test-user',
'--email=' . $email,
'--password=' . $password,
'--role=' . $roleName,
'--firstName=' . $firstName,
'--lastName=' . $lastName,
'--school=' . $schoolName,
'--tenant=' . $tenantConfig->subdomain,
'--internal-run',
],
cwd: $this->projectDir,
env: [
...getenv(),
'DATABASE_URL' => $tenantConfig->databaseUrl,
],
timeout: 300,
);
$process->run(static function (string $type, string $buffer) use ($io): void {
$io->write($buffer);
});
if ($process->isSuccessful()) {
return Command::SUCCESS;
}
$io->error(sprintf(
'Failed to create test user in tenant database "%s".',
$tenantConfig->subdomain,
));
return Command::FAILURE;
}
} }

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Messenger;
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
final readonly class TenantDatabaseMiddleware implements MiddlewareInterface
{
public function __construct(
private TenantRegistry $tenantRegistry,
private TenantDatabaseSwitcher $databaseSwitcher,
private TenantMessageTenantIdResolver $tenantIdResolver,
) {
}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$tenantId = $this->tenantIdResolver->resolve($envelope->getMessage());
if ($tenantId === null) {
return $stack->next()->handle($envelope, $stack);
}
$previousDatabaseUrl = $this->databaseSwitcher->currentDatabaseUrl();
$tenantConfig = $this->tenantRegistry->getConfig(TenantId::fromString($tenantId));
$this->databaseSwitcher->useTenantDatabase($tenantConfig->databaseUrl);
try {
return $stack->next()->handle($envelope, $stack);
} finally {
if ($previousDatabaseUrl !== null) {
$this->databaseSwitcher->useTenantDatabase($previousDatabaseUrl);
} else {
$this->databaseSwitcher->useDefaultDatabase();
}
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Messenger;
use App\Shared\Domain\Tenant\TenantId as DomainTenantId;
use function get_object_vars;
use function is_string;
use function trim;
final readonly class TenantMessageTenantIdResolver
{
public function resolve(object $message): ?string
{
$vars = get_object_vars($message);
if (!isset($vars['tenantId'])) {
return null;
}
$tenantId = $vars['tenantId'];
if ($tenantId instanceof DomainTenantId) {
return (string) $tenantId;
}
if (!is_string($tenantId)) {
return null;
}
$tenantId = trim($tenantId);
return $tenantId !== '' ? $tenantId : null;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Persistence\Doctrine;
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
use function array_merge;
use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Connection\StaticServerVersionProvider;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Connection as DriverConnection;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Tools\DsnParser;
use function is_array;
use function is_string;
use RuntimeException;
use SensitiveParameter;
/**
* @phpstan-import-type Params from DriverManager
*/
final class TenantAwareConnection extends Connection implements TenantDatabaseSwitcher
{
private const array URL_SCHEME_MAP = [
'db2' => 'ibm_db2',
'mssql' => 'pdo_sqlsrv',
'mysql' => 'pdo_mysql',
'mysql2' => 'pdo_mysql',
'postgres' => 'pdo_pgsql',
'postgresql' => 'pdo_pgsql',
'pgsql' => 'pdo_pgsql',
'sqlite' => 'pdo_sqlite',
'sqlite3' => 'pdo_sqlite',
];
/** @phpstan-var Params */
private array $defaultConnectionParams;
/** @phpstan-var Params */
private array $currentConnectionParams;
private ?string $currentDatabaseUrl = null;
private ?AbstractPlatform $currentPlatform = null;
private readonly DsnParser $dsnParser;
/**
* @phpstan-param Params $params
*/
public function __construct(
#[SensitiveParameter]
array $params,
Driver $driver,
?Configuration $config = null,
) {
parent::__construct($params, $driver, $config);
$this->defaultConnectionParams = $params;
$this->currentConnectionParams = $params;
$this->dsnParser = new DsnParser(self::URL_SCHEME_MAP);
}
public function useTenantDatabase(string $databaseUrl): void
{
/** @phpstan-var Params $connectionParams */
$connectionParams = array_merge($this->defaultConnectionParams, $this->dsnParser->parse($databaseUrl));
$this->applyConnectionParams($connectionParams, $databaseUrl);
}
public function useDefaultDatabase(): void
{
$this->applyConnectionParams($this->defaultConnectionParams, null);
}
public function currentDatabaseUrl(): ?string
{
return $this->currentDatabaseUrl;
}
/**
* @phpstan-return Params
*/
public function getParams(): array
{
return $this->currentConnectionParams;
}
public function getDatabasePlatform(): AbstractPlatform
{
if ($this->currentPlatform === null) {
$versionProvider = $this;
$serverVersion = $this->currentConnectionParams['serverVersion'] ?? null;
if (is_string($serverVersion)) {
$versionProvider = new StaticServerVersionProvider($serverVersion);
} else {
$primaryConnection = $this->currentConnectionParams['primary'] ?? null;
if (is_array($primaryConnection)) {
$primaryServerVersion = $primaryConnection['serverVersion'] ?? null;
if (is_string($primaryServerVersion)) {
$versionProvider = new StaticServerVersionProvider($primaryServerVersion);
}
}
}
$this->currentPlatform = $this->getDriver()->getDatabasePlatform($versionProvider);
}
return $this->currentPlatform;
}
public function close(): void
{
parent::close();
$this->currentPlatform = null;
}
protected function connect(): DriverConnection
{
if ($this->_conn !== null) {
return $this->_conn;
}
try {
$connection = $this->_conn = $this->getDriver()->connect($this->currentConnectionParams);
} catch (Driver\Exception $e) {
throw $this->convertException($e);
}
if (!$this->isAutoCommit()) {
$this->beginTransaction();
}
return $connection;
}
/**
* @phpstan-param Params $params
*/
private function applyConnectionParams(array $params, ?string $databaseUrl): void
{
if ($this->currentConnectionParams === $params && $this->currentDatabaseUrl === $databaseUrl) {
return;
}
if ($this->isConnected()) {
if ($this->isTransactionActive()) {
throw new RuntimeException('Cannot switch database while a transaction is active.');
}
$this->close();
}
$this->currentConnectionParams = $params;
$this->currentDatabaseUrl = $databaseUrl;
$this->currentPlatform = null;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final readonly class TenantDatabaseRequestSubscriber implements EventSubscriberInterface
{
public function __construct(
private TenantDatabaseSwitcher $databaseSwitcher,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 255],
KernelEvents::TERMINATE => 'onKernelTerminate',
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$tenant = $event->getRequest()->attributes->get('_tenant');
if (!$tenant instanceof TenantConfig) {
$this->databaseSwitcher->useDefaultDatabase();
return;
}
$this->databaseSwitcher->useTenantDatabase($tenant->databaseUrl);
}
public function onKernelTerminate(): void
{
$this->databaseSwitcher->useDefaultDatabase();
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
interface TenantDatabaseSwitcher
{
public function useTenantDatabase(string $databaseUrl): void;
public function useDefaultDatabase(): void;
public function currentDatabaseUrl(): ?string;
}

View File

@@ -49,6 +49,8 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
return; return;
} }
$this->context->clear();
$request = $event->getRequest(); $request = $event->getRequest();
$path = $request->getPathInfo(); $path = $request->getPathInfo();
$host = $request->getHost(); $host = $request->getHost();

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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,
);
}
}

View File

@@ -21,6 +21,8 @@ Options:
--zone ZONE School zone: A, B or C. Default: B --zone ZONE School zone: A, B or C. Default: B
--period-type TYPE Academic period type: trimester or semester. --period-type TYPE Academic period type: trimester or semester.
Default: trimester Default: trimester
--target TARGET Where to write demo data: master or tenant.
Default: tenant
--env-file PATH Override env file path. Default: ${ENV_FILE} --env-file PATH Override env file path. Default: ${ENV_FILE}
--compose-file PATH Override compose file path. Default: ${COMPOSE_FILE} --compose-file PATH Override compose file path. Default: ${COMPOSE_FILE}
--service NAME Override PHP service name. Default: ${PHP_SERVICE} --service NAME Override PHP service name. Default: ${PHP_SERVICE}
@@ -30,6 +32,7 @@ Examples:
./deploy/vps/generate-demo-data.sh ./deploy/vps/generate-demo-data.sh
./deploy/vps/generate-demo-data.sh --password 'Demo2026!' ./deploy/vps/generate-demo-data.sh --password 'Demo2026!'
./deploy/vps/generate-demo-data.sh --tenant demo --school 'College de demo' ./deploy/vps/generate-demo-data.sh --tenant demo --school 'College de demo'
./deploy/vps/generate-demo-data.sh --target master
EOF EOF
} }
@@ -70,6 +73,7 @@ PASSWORD="DemoPassword123!"
SCHOOL="" SCHOOL=""
ZONE="B" ZONE="B"
PERIOD_TYPE="trimester" PERIOD_TYPE="trimester"
TARGET="tenant"
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
@@ -113,6 +117,14 @@ while [ "$#" -gt 0 ]; do
PERIOD_TYPE="${1#*=}" PERIOD_TYPE="${1#*=}"
shift shift
;; ;;
--target)
TARGET="${2:-}"
shift 2
;;
--target=*)
TARGET="${1#*=}"
shift
;;
--env-file) --env-file)
ENV_FILE="${2:-}" ENV_FILE="${2:-}"
shift 2 shift 2
@@ -172,6 +184,15 @@ if [ -z "${TENANT}" ]; then
exit 1 exit 1
fi fi
case "${TARGET}" in
master|tenant)
;;
*)
echo "Invalid target: ${TARGET}. Expected 'master' or 'tenant'." >&2
exit 1
;;
esac
if [ -z "${SCHOOL}" ] && [ -t 0 ]; then if [ -z "${SCHOOL}" ] && [ -t 0 ]; then
SCHOOL=$(prompt_with_default "School name (optional)" "") SCHOOL=$(prompt_with_default "School name (optional)" "")
fi fi
@@ -188,6 +209,10 @@ COMMAND=(
"--period-type=${PERIOD_TYPE}" "--period-type=${PERIOD_TYPE}"
) )
if [ "${TARGET}" = "master" ]; then
COMMAND+=("--internal-run")
fi
if [ -n "${SCHOOL}" ]; then if [ -n "${SCHOOL}" ]; then
COMMAND+=("--school=${SCHOOL}") COMMAND+=("--school=${SCHOOL}")
fi fi
@@ -195,6 +220,7 @@ fi
echo "Running demo data generator for tenant: ${TENANT}" echo "Running demo data generator for tenant: ${TENANT}"
echo "Compose file: ${COMPOSE_FILE}" echo "Compose file: ${COMPOSE_FILE}"
echo "Env file: ${ENV_FILE}" echo "Env file: ${ENV_FILE}"
echo "Target database: ${TARGET}"
echo echo
"${COMMAND[@]}" "${COMMAND[@]}"

View File

@@ -317,6 +317,7 @@ Exemples :
./deploy/vps/generate-demo-data.sh --password 'Demo2026!' ./deploy/vps/generate-demo-data.sh --password 'Demo2026!'
./deploy/vps/generate-demo-data.sh --school 'College de demo' ./deploy/vps/generate-demo-data.sh --school 'College de demo'
./deploy/vps/generate-demo-data.sh --tenant demo --zone B --period-type trimester ./deploy/vps/generate-demo-data.sh --tenant demo --zone B --period-type trimester
./deploy/vps/generate-demo-data.sh --target master
``` ```
La commande utilise un mot de passe commun pour tous les comptes, avec une valeur par defaut si tu n'en fournis pas, et affiche tous les comptes crees. La commande utilise un mot de passe commun pour tous les comptes, avec une valeur par defaut si tu n'en fournis pas, et affiche tous les comptes crees.