feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
This commit is contained in:
@@ -74,7 +74,7 @@ development_status:
|
|||||||
2-12b-optimistic-update-pages-admin: done
|
2-12b-optimistic-update-pages-admin: done
|
||||||
2-13-personnalisation-visuelle-etablissement: done
|
2-13-personnalisation-visuelle-etablissement: done
|
||||||
2-15-organisation-sections-dashboard-admin: done
|
2-15-organisation-sections-dashboard-admin: done
|
||||||
2-17-provisioning-automatique-etablissements: ready-for-dev # Tâches post-MVP différées de 2-10
|
2-17-provisioning-automatique-etablissements: in-progress # Tâches post-MVP différées de 2-10
|
||||||
epic-2-retrospective: done
|
epic-2-retrospective: done
|
||||||
|
|
||||||
# Epic 3: Import & Onboarding (5 stories)
|
# Epic 3: Import & Onboarding (5 stories)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"symfony/console": "^8.0",
|
"symfony/console": "^8.0",
|
||||||
"symfony/doctrine-messenger": "^8.0",
|
"symfony/doctrine-messenger": "^8.0",
|
||||||
"symfony/dotenv": "^8.0",
|
"symfony/dotenv": "^8.0",
|
||||||
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "^8.0",
|
"symfony/framework-bundle": "^8.0",
|
||||||
"symfony/html-sanitizer": "8.0.*",
|
"symfony/html-sanitizer": "8.0.*",
|
||||||
|
|||||||
69
backend/composer.lock
generated
69
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "92b9472c96a59c314d96372c4094f185",
|
"content-hash": "a4b309e6d9d273339a5154a64a5f7361",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/core",
|
"name": "api-platform/core",
|
||||||
@@ -4672,6 +4672,73 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-09-25T14:21:43+00:00"
|
"time": "2024-09-25T14:21:43+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/expression-language",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/expression-language.git",
|
||||||
|
"reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/expression-language/zipball/b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b",
|
||||||
|
"reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/cache": "^7.4|^8.0",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\ExpressionLanguage\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides an engine that can compile and evaluate expressions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/expression-language/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/filesystem",
|
"name": "symfony/filesystem",
|
||||||
"version": "v8.0.1",
|
"version": "v8.0.1",
|
||||||
|
|||||||
@@ -64,3 +64,5 @@ framework:
|
|||||||
# Import élèves/enseignants → async (batch processing, peut être long)
|
# Import élèves/enseignants → async (batch processing, peut être long)
|
||||||
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
|
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
|
||||||
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async
|
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async
|
||||||
|
# Provisioning établissement → async (création BDD, migrations, premier admin)
|
||||||
|
App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand: async
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
# Configuration des tenants en production
|
# Tenants en production : résolution dynamique depuis la base establishments
|
||||||
#
|
#
|
||||||
# En production, les tenants peuvent être configurés de deux façons :
|
# Le DoctrineTenantRegistry interroge la table establishments sur la base master.
|
||||||
# 1. Via la variable d'environnement TENANT_CONFIGS (JSON)
|
# Les nouveaux établissements sont immédiatement accessibles via leur sous-domaine
|
||||||
# 2. Via une implémentation DatabaseTenantRegistry (à implémenter)
|
# sans redémarrage de l'application.
|
||||||
#
|
|
||||||
# Pour l'instant, on utilise InMemoryTenantRegistry avec configuration env.
|
|
||||||
# Si aucun tenant n'est configuré, toutes les requêtes retourneront 404.
|
|
||||||
|
|
||||||
parameters:
|
|
||||||
# Format JSON attendu: [{"tenantId":"uuid","subdomain":"ecole","databaseUrl":"postgres://..."}]
|
|
||||||
tenant.prod_configs_json: '%env(default::TENANT_CONFIGS)%'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
App\Shared\Infrastructure\Tenant\TenantRegistry:
|
App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry:
|
||||||
class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry
|
|
||||||
factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromEnv']
|
|
||||||
arguments:
|
arguments:
|
||||||
$configsJson: '%tenant.prod_configs_json%'
|
$connection: '@doctrine.dbal.master_connection'
|
||||||
|
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||||
|
|
||||||
|
App\Shared\Infrastructure\Tenant\TenantRegistry:
|
||||||
|
alias: App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry
|
||||||
|
|||||||
@@ -333,6 +333,23 @@ services:
|
|||||||
App\SuperAdmin\Domain\Repository\EstablishmentRepository:
|
App\SuperAdmin\Domain\Repository\EstablishmentRepository:
|
||||||
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository
|
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository
|
||||||
|
|
||||||
|
# Provisioning (Story 2.17 - Provisioning automatique)
|
||||||
|
App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator:
|
||||||
|
arguments:
|
||||||
|
$connection: '@doctrine.dbal.master_connection'
|
||||||
|
|
||||||
|
App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||||
|
|
||||||
|
App\SuperAdmin\Application\Port\TenantProvisioner:
|
||||||
|
alias: App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner
|
||||||
|
|
||||||
|
App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler:
|
||||||
|
arguments:
|
||||||
|
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||||
|
|
||||||
# School Calendar Repository (Story 2.11 - Calendrier scolaire)
|
# School Calendar Repository (Story 2.11 - Calendrier scolaire)
|
||||||
App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository:
|
App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository:
|
||||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository
|
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Tenant;
|
||||||
|
|
||||||
|
use function array_values;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
use function parse_url;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Contracts\Service\ResetInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves tenants dynamically from the establishments table in the master database.
|
||||||
|
*
|
||||||
|
* Unlike InMemoryTenantRegistry (loaded from static config), this implementation
|
||||||
|
* makes newly created establishments immediately accessible via their subdomain
|
||||||
|
* without restarting the application.
|
||||||
|
*
|
||||||
|
* Results are lazy-loaded and cached in memory for the duration of the request.
|
||||||
|
* Implements ResetInterface so long-running workers invalidate the cache between messages.
|
||||||
|
*/
|
||||||
|
final class DoctrineTenantRegistry implements TenantRegistry, ResetInterface
|
||||||
|
{
|
||||||
|
/** @var array<string, TenantConfig>|null Indexed by tenant ID */
|
||||||
|
private ?array $byId = null;
|
||||||
|
|
||||||
|
/** @var array<string, TenantConfig>|null Indexed by subdomain */
|
||||||
|
private ?array $bySubdomain = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
private readonly string $masterDatabaseUrl,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function getConfig(TenantId $tenantId): TenantConfig
|
||||||
|
{
|
||||||
|
$this->ensureLoaded();
|
||||||
|
|
||||||
|
$key = (string) $tenantId;
|
||||||
|
|
||||||
|
if (!isset($this->byId[$key])) {
|
||||||
|
throw TenantNotFoundException::withId($tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->byId[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function getBySubdomain(string $subdomain): TenantConfig
|
||||||
|
{
|
||||||
|
$this->ensureLoaded();
|
||||||
|
|
||||||
|
if (!isset($this->bySubdomain[$subdomain])) {
|
||||||
|
throw TenantNotFoundException::withSubdomain($subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->bySubdomain[$subdomain];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function exists(string $subdomain): bool
|
||||||
|
{
|
||||||
|
$this->ensureLoaded();
|
||||||
|
|
||||||
|
return isset($this->bySubdomain[$subdomain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function getAllConfigs(): array
|
||||||
|
{
|
||||||
|
$this->ensureLoaded();
|
||||||
|
|
||||||
|
/** @var array<string, TenantConfig> $byId */
|
||||||
|
$byId = $this->byId;
|
||||||
|
|
||||||
|
return array_values($byId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function reset(): void
|
||||||
|
{
|
||||||
|
$this->byId = null;
|
||||||
|
$this->bySubdomain = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureLoaded(): void
|
||||||
|
{
|
||||||
|
if ($this->byId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->byId = [];
|
||||||
|
$this->bySubdomain = [];
|
||||||
|
|
||||||
|
/** @var array<array{tenant_id: string, subdomain: string, database_name: string}> $rows */
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
"SELECT tenant_id, subdomain, database_name FROM establishments WHERE status = 'active'",
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$config = new TenantConfig(
|
||||||
|
tenantId: TenantId::fromString($row['tenant_id']),
|
||||||
|
subdomain: $row['subdomain'],
|
||||||
|
databaseUrl: $this->buildDatabaseUrl($row['database_name']),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->byId[$row['tenant_id']] = $config;
|
||||||
|
$this->bySubdomain[$row['subdomain']] = $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDatabaseUrl(string $databaseName): string
|
||||||
|
{
|
||||||
|
$parsed = parse_url($this->masterDatabaseUrl);
|
||||||
|
|
||||||
|
$scheme = $parsed['scheme'] ?? 'postgresql';
|
||||||
|
$user = $parsed['user'] ?? '';
|
||||||
|
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
|
||||||
|
$host = $parsed['host'] ?? 'localhost';
|
||||||
|
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||||
|
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||||
|
|
||||||
|
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,23 +17,18 @@ final readonly class CreateEstablishmentHandler
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __invoke(CreateEstablishmentCommand $command): CreateEstablishmentResult
|
public function __invoke(CreateEstablishmentCommand $command): Establishment
|
||||||
{
|
{
|
||||||
$establishment = Establishment::creer(
|
$establishment = Establishment::creer(
|
||||||
name: $command->name,
|
name: $command->name,
|
||||||
subdomain: $command->subdomain,
|
subdomain: $command->subdomain,
|
||||||
|
adminEmail: $command->adminEmail,
|
||||||
createdBy: SuperAdminId::fromString($command->superAdminId),
|
createdBy: SuperAdminId::fromString($command->superAdminId),
|
||||||
createdAt: $this->clock->now(),
|
createdAt: $this->clock->now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->establishmentRepository->save($establishment);
|
$this->establishmentRepository->save($establishment);
|
||||||
|
|
||||||
return new CreateEstablishmentResult(
|
return $establishment;
|
||||||
establishmentId: (string) $establishment->id,
|
|
||||||
tenantId: (string) $establishment->tenantId,
|
|
||||||
name: $establishment->name,
|
|
||||||
subdomain: $establishment->subdomain,
|
|
||||||
databaseName: $establishment->databaseName,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\SuperAdmin\Application\Command\CreateEstablishment;
|
|
||||||
|
|
||||||
final readonly class CreateEstablishmentResult
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $establishmentId,
|
|
||||||
public string $tenantId,
|
|
||||||
public string $name,
|
|
||||||
public string $subdomain,
|
|
||||||
public string $databaseName,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Command\ProvisionEstablishment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers async provisioning of a newly created establishment.
|
||||||
|
*
|
||||||
|
* Property names intentionally avoid "tenantId" to prevent the
|
||||||
|
* TenantDatabaseMiddleware from trying to switch to a database
|
||||||
|
* that doesn't exist yet.
|
||||||
|
*/
|
||||||
|
final readonly class ProvisionEstablishmentCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $establishmentId,
|
||||||
|
public string $establishmentTenantId,
|
||||||
|
public string $databaseName,
|
||||||
|
public string $subdomain,
|
||||||
|
public string $adminEmail,
|
||||||
|
public string $establishmentName,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Port;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a tenant database and runs migrations.
|
||||||
|
*/
|
||||||
|
interface TenantProvisioner
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Creates the tenant database and applies the schema.
|
||||||
|
*
|
||||||
|
* @throws RuntimeException if provisioning fails
|
||||||
|
*/
|
||||||
|
public function provision(string $databaseName): void;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ final readonly class EtablissementCree implements DomainEvent
|
|||||||
public TenantId $tenantId,
|
public TenantId $tenantId,
|
||||||
public string $name,
|
public string $name,
|
||||||
public string $subdomain,
|
public string $subdomain,
|
||||||
|
public string $adminEmail,
|
||||||
private DateTimeImmutable $occurredOn,
|
private DateTimeImmutable $occurredOn,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ final class Establishment extends AggregateRoot
|
|||||||
public static function creer(
|
public static function creer(
|
||||||
string $name,
|
string $name,
|
||||||
string $subdomain,
|
string $subdomain,
|
||||||
|
string $adminEmail,
|
||||||
SuperAdminId $createdBy,
|
SuperAdminId $createdBy,
|
||||||
DateTimeImmutable $createdAt,
|
DateTimeImmutable $createdAt,
|
||||||
): self {
|
): self {
|
||||||
@@ -49,7 +50,7 @@ final class Establishment extends AggregateRoot
|
|||||||
name: $name,
|
name: $name,
|
||||||
subdomain: $subdomain,
|
subdomain: $subdomain,
|
||||||
databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)),
|
databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)),
|
||||||
status: EstablishmentStatus::ACTIF,
|
status: EstablishmentStatus::PROVISIONING,
|
||||||
createdAt: $createdAt,
|
createdAt: $createdAt,
|
||||||
createdBy: $createdBy,
|
createdBy: $createdBy,
|
||||||
);
|
);
|
||||||
@@ -59,12 +60,18 @@ final class Establishment extends AggregateRoot
|
|||||||
tenantId: $establishment->tenantId,
|
tenantId: $establishment->tenantId,
|
||||||
name: $name,
|
name: $name,
|
||||||
subdomain: $subdomain,
|
subdomain: $subdomain,
|
||||||
|
adminEmail: $adminEmail,
|
||||||
occurredOn: $createdAt,
|
occurredOn: $createdAt,
|
||||||
));
|
));
|
||||||
|
|
||||||
return $establishment;
|
return $establishment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function activer(): void
|
||||||
|
{
|
||||||
|
$this->status = EstablishmentStatus::ACTIF;
|
||||||
|
}
|
||||||
|
|
||||||
public function desactiver(DateTimeImmutable $at): void
|
public function desactiver(DateTimeImmutable $at): void
|
||||||
{
|
{
|
||||||
if ($this->status !== EstablishmentStatus::ACTIF) {
|
if ($this->status !== EstablishmentStatus::ACTIF) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\SuperAdmin\Domain\Model\Establishment;
|
|||||||
|
|
||||||
enum EstablishmentStatus: string
|
enum EstablishmentStatus: string
|
||||||
{
|
{
|
||||||
|
case PROVISIONING = 'provisioning';
|
||||||
case ACTIF = 'active';
|
case ACTIF = 'active';
|
||||||
case INACTIF = 'inactive';
|
case INACTIF = 'inactive';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
|
||||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
use Override;
|
use Override;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements ProcessorInterface<EstablishmentResource, EstablishmentResource>
|
* @implements ProcessorInterface<EstablishmentResource, EstablishmentResource>
|
||||||
@@ -21,6 +23,7 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private CreateEstablishmentHandler $handler,
|
private CreateEstablishmentHandler $handler,
|
||||||
private Security $security,
|
private Security $security,
|
||||||
|
private MessageBusInterface $commandBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,20 +36,29 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
|
|||||||
/** @var SecuritySuperAdmin $user */
|
/** @var SecuritySuperAdmin $user */
|
||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
$result = ($this->handler)(new CreateEstablishmentCommand(
|
$establishment = ($this->handler)(new CreateEstablishmentCommand(
|
||||||
name: $data->name,
|
name: $data->name,
|
||||||
subdomain: $data->subdomain,
|
subdomain: $data->subdomain,
|
||||||
adminEmail: $data->adminEmail,
|
adminEmail: $data->adminEmail,
|
||||||
superAdminId: $user->superAdminId(),
|
superAdminId: $user->superAdminId(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
$this->commandBus->dispatch(new ProvisionEstablishmentCommand(
|
||||||
|
establishmentId: (string) $establishment->id,
|
||||||
|
establishmentTenantId: (string) $establishment->tenantId,
|
||||||
|
databaseName: $establishment->databaseName,
|
||||||
|
subdomain: $establishment->subdomain,
|
||||||
|
adminEmail: $data->adminEmail,
|
||||||
|
establishmentName: $establishment->name,
|
||||||
|
));
|
||||||
|
|
||||||
$resource = new EstablishmentResource();
|
$resource = new EstablishmentResource();
|
||||||
$resource->id = $result->establishmentId;
|
$resource->id = (string) $establishment->id;
|
||||||
$resource->tenantId = $result->tenantId;
|
$resource->tenantId = (string) $establishment->tenantId;
|
||||||
$resource->name = $result->name;
|
$resource->name = $establishment->name;
|
||||||
$resource->subdomain = $result->subdomain;
|
$resource->subdomain = $establishment->subdomain;
|
||||||
$resource->databaseName = $result->databaseName;
|
$resource->databaseName = $establishment->databaseName;
|
||||||
$resource->status = 'active';
|
$resource->status = $establishment->status->value;
|
||||||
|
|
||||||
return $resource;
|
return $resource;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provisions a tenant by creating the database and running migrations.
|
||||||
|
*/
|
||||||
|
final readonly class DatabaseTenantProvisioner implements TenantProvisioner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TenantDatabaseCreator $databaseCreator,
|
||||||
|
private TenantMigrator $migrator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function provision(string $databaseName): void
|
||||||
|
{
|
||||||
|
$this->databaseCreator->create($databaseName);
|
||||||
|
$this->migrator->migrate($databaseName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\InviteUser\InviteUserCommand;
|
||||||
|
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||||
|
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
|
||||||
|
|
||||||
|
use function parse_url;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the complete provisioning of a new establishment:
|
||||||
|
* 1. Creates the tenant database and runs migrations
|
||||||
|
* 2. Creates the first admin user (idempotent)
|
||||||
|
* 3. Activates the establishment
|
||||||
|
* 4. Dispatches invitation events (after activation so the tenant is resolvable)
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class ProvisionEstablishmentHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TenantProvisioner $tenantProvisioner,
|
||||||
|
private InviteUserHandler $inviteUserHandler,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
private TenantDatabaseSwitcher $databaseSwitcher,
|
||||||
|
private EstablishmentRepository $establishmentRepository,
|
||||||
|
private MessageBusInterface $eventBus,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private string $masterDatabaseUrl,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(ProvisionEstablishmentCommand $command): void
|
||||||
|
{
|
||||||
|
$this->logger->info('Starting establishment provisioning.', [
|
||||||
|
'establishment' => $command->establishmentId,
|
||||||
|
'subdomain' => $command->subdomain,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tenantProvisioner->provision($command->databaseName);
|
||||||
|
|
||||||
|
// Create admin user on the tenant database, collect events without dispatching
|
||||||
|
$pendingEvents = $this->createFirstAdminOnTenantDb($command);
|
||||||
|
|
||||||
|
// Activate establishment on master DB so the tenant becomes resolvable
|
||||||
|
$this->activateEstablishment($command->establishmentId);
|
||||||
|
|
||||||
|
// Now dispatch events — the tenant is active and resolvable by the middleware
|
||||||
|
foreach ($pendingEvents as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Establishment provisioning completed.', [
|
||||||
|
'establishment' => $command->establishmentId,
|
||||||
|
'subdomain' => $command->subdomain,
|
||||||
|
'adminEmail' => $command->adminEmail,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DomainEvent[]
|
||||||
|
*/
|
||||||
|
private function createFirstAdminOnTenantDb(ProvisionEstablishmentCommand $command): array
|
||||||
|
{
|
||||||
|
$tenantDatabaseUrl = $this->buildTenantDatabaseUrl($command->databaseName);
|
||||||
|
$this->databaseSwitcher->useTenantDatabase($tenantDatabaseUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->createFirstAdmin($command);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->restoreDefaultDatabase();
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
$this->restoreDefaultDatabase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DomainEvent[]
|
||||||
|
*/
|
||||||
|
private function createFirstAdmin(ProvisionEstablishmentCommand $command): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$user = ($this->inviteUserHandler)(new InviteUserCommand(
|
||||||
|
tenantId: $command->establishmentTenantId,
|
||||||
|
schoolName: $command->establishmentName,
|
||||||
|
email: $command->adminEmail,
|
||||||
|
role: Role::ADMIN->value,
|
||||||
|
firstName: 'Administrateur',
|
||||||
|
lastName: $command->establishmentName,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $user->pullDomainEvents();
|
||||||
|
} catch (EmailDejaUtiliseeException) {
|
||||||
|
$this->logger->info('Admin already exists, re-sending invitation.', [
|
||||||
|
'email' => $command->adminEmail,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->resendInvitation($command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DomainEvent[]
|
||||||
|
*/
|
||||||
|
private function resendInvitation(ProvisionEstablishmentCommand $command): array
|
||||||
|
{
|
||||||
|
$existingUser = $this->userRepository->findByEmail(
|
||||||
|
new Email($command->adminEmail),
|
||||||
|
TenantId::fromString($command->establishmentTenantId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existingUser === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingUser->renvoyerInvitation($this->clock->now());
|
||||||
|
$this->userRepository->save($existingUser);
|
||||||
|
|
||||||
|
return $existingUser->pullDomainEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activateEstablishment(string $establishmentId): void
|
||||||
|
{
|
||||||
|
$establishment = $this->establishmentRepository->get(
|
||||||
|
EstablishmentId::fromString($establishmentId),
|
||||||
|
);
|
||||||
|
$establishment->activer();
|
||||||
|
$this->establishmentRepository->save($establishment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function restoreDefaultDatabase(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->databaseSwitcher->useDefaultDatabase();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('Failed to restore default database connection.', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTenantDatabaseUrl(string $databaseName): string
|
||||||
|
{
|
||||||
|
$parsed = parse_url($this->masterDatabaseUrl);
|
||||||
|
|
||||||
|
$scheme = $parsed['scheme'] ?? 'postgresql';
|
||||||
|
$user = $parsed['user'] ?? '';
|
||||||
|
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
|
||||||
|
$host = $parsed['host'] ?? 'localhost';
|
||||||
|
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||||
|
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||||
|
|
||||||
|
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use function preg_match;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
use function str_replace;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a PostgreSQL database for a new tenant.
|
||||||
|
*
|
||||||
|
* Extracted from the tenant:database:create console command
|
||||||
|
* to be usable programmatically during provisioning.
|
||||||
|
*/
|
||||||
|
final readonly class TenantDatabaseCreator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private string $databaseUser = 'classeo',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws RuntimeException if database name is invalid or creation fails
|
||||||
|
*/
|
||||||
|
public function create(string $databaseName): void
|
||||||
|
{
|
||||||
|
if (!preg_match('/^classeo_tenant_[a-z0-9_]+$/', $databaseName)) {
|
||||||
|
throw new RuntimeException(sprintf('Invalid tenant database name: "%s"', $databaseName));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$exists = $this->connection->fetchOne(
|
||||||
|
'SELECT 1 FROM pg_database WHERE datname = :name',
|
||||||
|
['name' => $databaseName],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($exists !== false) {
|
||||||
|
$this->logger->info('Tenant database already exists, skipping creation.', [
|
||||||
|
'database' => $databaseName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->connection->executeStatement(sprintf(
|
||||||
|
"CREATE DATABASE %s WITH OWNER = %s ENCODING = 'UTF8' LC_COLLATE = 'en_US.utf8' LC_CTYPE = 'en_US.utf8'",
|
||||||
|
$this->quoteIdentifier($databaseName),
|
||||||
|
$this->quoteIdentifier($this->databaseUser),
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->logger->info('Tenant database created.', ['database' => $databaseName]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf('Failed to create tenant database "%s": %s', $databaseName, $e->getMessage()),
|
||||||
|
previous: $e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quoteIdentifier(string $identifier): string
|
||||||
|
{
|
||||||
|
return '"' . str_replace('"', '""', $identifier) . '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use function getenv;
|
||||||
|
use function parse_url;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs Doctrine migrations for a tenant database.
|
||||||
|
*
|
||||||
|
* Spawns a subprocess with DATABASE_URL pointing to the tenant database,
|
||||||
|
* so Doctrine connects to the correct database before the kernel boots.
|
||||||
|
*/
|
||||||
|
final readonly class TenantMigrator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private string $projectDir,
|
||||||
|
private string $masterDatabaseUrl,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws RuntimeException if migration fails
|
||||||
|
*/
|
||||||
|
public function migrate(string $databaseName): void
|
||||||
|
{
|
||||||
|
$databaseUrl = $this->buildDatabaseUrl($databaseName);
|
||||||
|
|
||||||
|
$process = new Process(
|
||||||
|
command: ['php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction'],
|
||||||
|
cwd: $this->projectDir,
|
||||||
|
env: [
|
||||||
|
...getenv(),
|
||||||
|
'DATABASE_URL' => $databaseUrl,
|
||||||
|
],
|
||||||
|
timeout: 300,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->info('Running migrations for tenant database.', ['database' => $databaseName]);
|
||||||
|
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (!$process->isSuccessful()) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Migration failed for tenant database "%s": %s',
|
||||||
|
$databaseName,
|
||||||
|
$process->getErrorOutput(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Migrations completed for tenant database.', ['database' => $databaseName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDatabaseUrl(string $databaseName): string
|
||||||
|
{
|
||||||
|
$parsed = parse_url($this->masterDatabaseUrl);
|
||||||
|
|
||||||
|
$scheme = $parsed['scheme'] ?? 'postgresql';
|
||||||
|
$user = $parsed['user'] ?? '';
|
||||||
|
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
|
||||||
|
$host = $parsed['host'] ?? 'localhost';
|
||||||
|
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||||
|
|
||||||
|
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||||
|
|
||||||
|
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Shared\Infrastructure\Audit;
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||||
|
use App\Shared\Application\Port\AuditLogger;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [P1] Functional tests for audit trail infrastructure.
|
||||||
|
*
|
||||||
|
* Verifies that the AuditLogger writes to the real audit_log table
|
||||||
|
* and that entries contain correct metadata.
|
||||||
|
*
|
||||||
|
* @see NFR-S7: Audit trail immutable (qui, quoi, quand)
|
||||||
|
* @see FR90: Tracage actions sensibles
|
||||||
|
*/
|
||||||
|
final class AuditTrailFunctionalTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
protected static ?bool $alwaysBootKernel = true;
|
||||||
|
|
||||||
|
private Connection $connection;
|
||||||
|
private AuditLogger $auditLogger;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
static::bootKernel();
|
||||||
|
$container = static::getContainer();
|
||||||
|
|
||||||
|
/* @var Connection $connection */
|
||||||
|
$this->connection = $container->get(Connection::class);
|
||||||
|
|
||||||
|
/* @var AuditLogger $auditLogger */
|
||||||
|
$this->auditLogger = $container->get(AuditLogger::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function logAuthenticationWritesEntryToAuditLogTable(): void
|
||||||
|
{
|
||||||
|
$userId = Uuid::uuid4();
|
||||||
|
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionReussie',
|
||||||
|
userId: $userId,
|
||||||
|
payload: [
|
||||||
|
'email_hash' => hash('sha256', 'test@example.com'),
|
||||||
|
'result' => 'success',
|
||||||
|
'method' => 'password',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$entry = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||||
|
[$userId->toString(), 'ConnexionReussie'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotFalse($entry, 'Audit log entry should exist after logAuthentication');
|
||||||
|
self::assertSame('User', $entry['aggregate_type']);
|
||||||
|
self::assertSame($userId->toString(), $entry['aggregate_id']);
|
||||||
|
self::assertSame('ConnexionReussie', $entry['event_type']);
|
||||||
|
|
||||||
|
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame('success', $payload['result']);
|
||||||
|
self::assertSame('password', $payload['method']);
|
||||||
|
self::assertArrayHasKey('email_hash', $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function logAuthenticationIncludesMetadataWithTimestamp(): void
|
||||||
|
{
|
||||||
|
$userId = Uuid::uuid4();
|
||||||
|
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionReussie',
|
||||||
|
userId: $userId,
|
||||||
|
payload: ['result' => 'success'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$entry = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM audit_log WHERE aggregate_id = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||||
|
[$userId->toString()],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotFalse($entry);
|
||||||
|
self::assertNotEmpty($entry['occurred_at'], 'Audit entry must have a timestamp');
|
||||||
|
|
||||||
|
$metadata = json_decode($entry['metadata'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertIsArray($metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function logFailedAuthenticationWritesWithNullUserId(): void
|
||||||
|
{
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionEchouee',
|
||||||
|
userId: null,
|
||||||
|
payload: [
|
||||||
|
'email_hash' => hash('sha256', 'unknown@example.com'),
|
||||||
|
'result' => 'failure',
|
||||||
|
'reason' => 'invalid_credentials',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$entry = $this->connection->fetchAssociative(
|
||||||
|
"SELECT * FROM audit_log WHERE event_type = 'ConnexionEchouee' ORDER BY occurred_at DESC LIMIT 1",
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotFalse($entry, 'Failed login audit entry should exist');
|
||||||
|
self::assertNull($entry['aggregate_id'], 'Failed login should have null user ID');
|
||||||
|
self::assertSame('User', $entry['aggregate_type']);
|
||||||
|
|
||||||
|
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame('failure', $payload['result']);
|
||||||
|
self::assertSame('invalid_credentials', $payload['reason']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function logDataChangeWritesOldAndNewValues(): void
|
||||||
|
{
|
||||||
|
$aggregateId = Uuid::uuid4();
|
||||||
|
|
||||||
|
$this->auditLogger->logDataChange(
|
||||||
|
aggregateType: 'Grade',
|
||||||
|
aggregateId: $aggregateId,
|
||||||
|
eventType: 'GradeModified',
|
||||||
|
oldValues: ['value' => 14.0],
|
||||||
|
newValues: ['value' => 16.0],
|
||||||
|
reason: 'Correction erreur de saisie',
|
||||||
|
);
|
||||||
|
|
||||||
|
$entry = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||||
|
[$aggregateId->toString(), 'GradeModified'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotFalse($entry);
|
||||||
|
self::assertSame('Grade', $entry['aggregate_type']);
|
||||||
|
|
||||||
|
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame(['value' => 14.0], $payload['old_values']);
|
||||||
|
self::assertSame(['value' => 16.0], $payload['new_values']);
|
||||||
|
self::assertSame('Correction erreur de saisie', $payload['reason']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function auditLogEntriesAreAppendOnly(): void
|
||||||
|
{
|
||||||
|
$userId = Uuid::uuid4();
|
||||||
|
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionReussie',
|
||||||
|
userId: $userId,
|
||||||
|
payload: ['result' => 'success'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$countBefore = (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||||
|
[$userId->toString()],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(1, $countBefore);
|
||||||
|
|
||||||
|
// Log a second event for the same user
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionReussie',
|
||||||
|
userId: $userId,
|
||||||
|
payload: ['result' => 'success'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$countAfter = (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||||
|
[$userId->toString()],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both entries should exist (append-only, no overwrite)
|
||||||
|
self::assertSame(2, $countAfter, 'Audit log must be append-only — both entries should exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(DoctrineTenantRegistry::class)]
|
||||||
|
final class DoctrineTenantRegistryTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||||
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
private const string SUBDOMAIN = 'ecole-alpha';
|
||||||
|
private const string DB_NAME = 'classeo_tenant_a1b2c3d4e5f67890abcdef1234567890';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itResolvesConfigBySubdomain(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$config = $registry->getBySubdomain(self::SUBDOMAIN);
|
||||||
|
|
||||||
|
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||||
|
self::assertSame(self::TENANT_ID, (string) $config->tenantId);
|
||||||
|
self::assertSame('postgresql://classeo:secret@db:5432/' . self::DB_NAME, $config->databaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itResolvesConfigByTenantId(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$config = $registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||||
|
|
||||||
|
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsForUnknownSubdomain(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([]);
|
||||||
|
|
||||||
|
$this->expectException(TenantNotFoundException::class);
|
||||||
|
$registry->getBySubdomain('inexistant');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsForUnknownTenantId(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([]);
|
||||||
|
|
||||||
|
$this->expectException(TenantNotFoundException::class);
|
||||||
|
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itChecksExistence(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue($registry->exists(self::SUBDOMAIN));
|
||||||
|
self::assertFalse($registry->exists('inexistant'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsAllConfigs(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
['tenant_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'subdomain' => 'ecole-beta', 'database_name' => 'classeo_tenant_beta'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$configs = $registry->getAllConfigs();
|
||||||
|
|
||||||
|
self::assertCount(2, $configs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itQueriesDatabaseOnlyOnce(): void
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection->expects(self::once())
|
||||||
|
->method('fetchAllAssociative')
|
||||||
|
->willReturn([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$registry = new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||||
|
|
||||||
|
$registry->getBySubdomain(self::SUBDOMAIN);
|
||||||
|
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||||
|
$registry->exists(self::SUBDOMAIN);
|
||||||
|
$registry->getAllConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array{tenant_id: string, subdomain: string, database_name: string}> $rows
|
||||||
|
*/
|
||||||
|
private function registryWith(array $rows): DoctrineTenantRegistry
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection->method('fetchAllAssociative')->willReturn($rows);
|
||||||
|
|
||||||
|
return new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function createsEstablishmentAndReturnsResult(): void
|
public function createsEstablishmentAndReturnsIt(): void
|
||||||
{
|
{
|
||||||
$command = new CreateEstablishmentCommand(
|
$command = new CreateEstablishmentCommand(
|
||||||
name: 'École Alpha',
|
name: 'École Alpha',
|
||||||
@@ -46,13 +46,13 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
|||||||
superAdminId: self::SUPER_ADMIN_ID,
|
superAdminId: self::SUPER_ADMIN_ID,
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = ($this->handler)($command);
|
$establishment = ($this->handler)($command);
|
||||||
|
|
||||||
self::assertNotEmpty($result->establishmentId);
|
self::assertNotEmpty((string) $establishment->id);
|
||||||
self::assertNotEmpty($result->tenantId);
|
self::assertNotEmpty((string) $establishment->tenantId);
|
||||||
self::assertSame('École Alpha', $result->name);
|
self::assertSame('École Alpha', $establishment->name);
|
||||||
self::assertSame('ecole-alpha', $result->subdomain);
|
self::assertSame('ecole-alpha', $establishment->subdomain);
|
||||||
self::assertStringStartsWith('classeo_tenant_', $result->databaseName);
|
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -65,10 +65,10 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
|||||||
superAdminId: self::SUPER_ADMIN_ID,
|
superAdminId: self::SUPER_ADMIN_ID,
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = ($this->handler)($command);
|
$establishment = ($this->handler)($command);
|
||||||
|
|
||||||
$establishments = $this->repository->findAll();
|
$establishments = $this->repository->findAll();
|
||||||
self::assertCount(1, $establishments);
|
self::assertCount(1, $establishments);
|
||||||
self::assertSame($result->establishmentId, (string) $establishments[0]->id);
|
self::assertSame((string) $establishment->id, (string) $establishments[0]->id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
|||||||
$this->repository->save(Establishment::creer(
|
$this->repository->save(Establishment::creer(
|
||||||
name: 'École Alpha',
|
name: 'École Alpha',
|
||||||
subdomain: 'ecole-alpha',
|
subdomain: 'ecole-alpha',
|
||||||
|
adminEmail: 'admin@ecole-alpha.fr',
|
||||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||||
));
|
));
|
||||||
@@ -47,6 +48,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
|||||||
$this->repository->save(Establishment::creer(
|
$this->repository->save(Establishment::creer(
|
||||||
name: 'École Beta',
|
name: 'École Beta',
|
||||||
subdomain: 'ecole-beta',
|
subdomain: 'ecole-beta',
|
||||||
|
adminEmail: 'admin@ecole-beta.fr',
|
||||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
createdAt: new DateTimeImmutable('2026-02-16 11:00:00'),
|
createdAt: new DateTimeImmutable('2026-02-16 11:00:00'),
|
||||||
));
|
));
|
||||||
@@ -56,6 +58,6 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
|||||||
self::assertCount(2, $result);
|
self::assertCount(2, $result);
|
||||||
self::assertSame('École Alpha', $result[0]->name);
|
self::assertSame('École Alpha', $result[0]->name);
|
||||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||||
self::assertSame('active', $result[0]->status);
|
self::assertSame('provisioning', $result[0]->status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ final class EstablishmentTest extends TestCase
|
|||||||
private const string SUBDOMAIN = 'ecole-alpha';
|
private const string SUBDOMAIN = 'ecole-alpha';
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function creerCreatesActiveEstablishment(): void
|
public function creerCreatesProvisioningEstablishment(): void
|
||||||
{
|
{
|
||||||
$establishment = $this->createEstablishment();
|
$establishment = $this->createEstablishment();
|
||||||
|
|
||||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||||
self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name);
|
self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name);
|
||||||
self::assertSame(self::SUBDOMAIN, $establishment->subdomain);
|
self::assertSame(self::SUBDOMAIN, $establishment->subdomain);
|
||||||
self::assertNull($establishment->lastActivityAt);
|
self::assertNull($establishment->lastActivityAt);
|
||||||
@@ -59,10 +59,21 @@ final class EstablishmentTest extends TestCase
|
|||||||
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function activerChangesStatusToActif(): void
|
||||||
|
{
|
||||||
|
$establishment = $this->createEstablishment();
|
||||||
|
|
||||||
|
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||||
|
$establishment->activer();
|
||||||
|
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function desactiverChangesStatusToInactif(): void
|
public function desactiverChangesStatusToInactif(): void
|
||||||
{
|
{
|
||||||
$establishment = $this->createEstablishment();
|
$establishment = $this->createEstablishment();
|
||||||
|
$establishment->activer();
|
||||||
|
|
||||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||||
|
|
||||||
@@ -73,6 +84,7 @@ final class EstablishmentTest extends TestCase
|
|||||||
public function desactiverRecordsEtablissementDesactiveEvent(): void
|
public function desactiverRecordsEtablissementDesactiveEvent(): void
|
||||||
{
|
{
|
||||||
$establishment = $this->createEstablishment();
|
$establishment = $this->createEstablishment();
|
||||||
|
$establishment->activer();
|
||||||
$establishment->pullDomainEvents(); // Clear creation event
|
$establishment->pullDomainEvents(); // Clear creation event
|
||||||
|
|
||||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||||
@@ -86,6 +98,7 @@ final class EstablishmentTest extends TestCase
|
|||||||
public function desactiverThrowsWhenAlreadyInactive(): void
|
public function desactiverThrowsWhenAlreadyInactive(): void
|
||||||
{
|
{
|
||||||
$establishment = $this->createEstablishment();
|
$establishment = $this->createEstablishment();
|
||||||
|
$establishment->activer();
|
||||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||||
|
|
||||||
$this->expectException(EstablishmentDejaInactifException::class);
|
$this->expectException(EstablishmentDejaInactifException::class);
|
||||||
@@ -141,6 +154,7 @@ final class EstablishmentTest extends TestCase
|
|||||||
return Establishment::creer(
|
return Establishment::creer(
|
||||||
name: self::ESTABLISHMENT_NAME,
|
name: self::ESTABLISHMENT_NAME,
|
||||||
subdomain: self::SUBDOMAIN,
|
subdomain: self::SUBDOMAIN,
|
||||||
|
adminEmail: 'admin@ecole-alpha.fr',
|
||||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Shared\Domain\Clock;
|
use App\Shared\Domain\Clock;
|
||||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
@@ -16,13 +17,15 @@ use DateTimeImmutable;
|
|||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
final class CreateEstablishmentProcessorTest extends TestCase
|
final class CreateEstablishmentProcessorTest extends TestCase
|
||||||
{
|
{
|
||||||
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function processCreatesEstablishmentAndReturnsResource(): void
|
public function processCreatesEstablishmentAndDispatchesProvisioning(): void
|
||||||
{
|
{
|
||||||
$repository = new InMemoryEstablishmentRepository();
|
$repository = new InMemoryEstablishmentRepository();
|
||||||
$clock = new class implements Clock {
|
$clock = new class implements Clock {
|
||||||
@@ -42,7 +45,16 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
|||||||
$security = $this->createMock(Security::class);
|
$security = $this->createMock(Security::class);
|
||||||
$security->method('getUser')->willReturn($securityUser);
|
$security->method('getUser')->willReturn($securityUser);
|
||||||
|
|
||||||
$processor = new CreateEstablishmentProcessor($handler, $security);
|
$dispatched = [];
|
||||||
|
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$commandBus->method('dispatch')
|
||||||
|
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
|
||||||
|
$dispatched[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
$processor = new CreateEstablishmentProcessor($handler, $security, $commandBus);
|
||||||
|
|
||||||
$input = new EstablishmentResource();
|
$input = new EstablishmentResource();
|
||||||
$input->name = 'École Gamma';
|
$input->name = 'École Gamma';
|
||||||
@@ -55,6 +67,12 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
|||||||
self::assertNotNull($result->tenantId);
|
self::assertNotNull($result->tenantId);
|
||||||
self::assertSame('École Gamma', $result->name);
|
self::assertSame('École Gamma', $result->name);
|
||||||
self::assertSame('ecole-gamma', $result->subdomain);
|
self::assertSame('ecole-gamma', $result->subdomain);
|
||||||
self::assertSame('active', $result->status);
|
self::assertSame('provisioning', $result->status);
|
||||||
|
|
||||||
|
self::assertCount(1, $dispatched);
|
||||||
|
self::assertInstanceOf(ProvisionEstablishmentCommand::class, $dispatched[0]);
|
||||||
|
self::assertSame('admin@ecole-gamma.fr', $dispatched[0]->adminEmail);
|
||||||
|
self::assertSame('ecole-gamma', $dispatched[0]->subdomain);
|
||||||
|
self::assertSame('École Gamma', $dispatched[0]->establishmentName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
|||||||
$repository->save(Establishment::creer(
|
$repository->save(Establishment::creer(
|
||||||
name: 'École Alpha',
|
name: 'École Alpha',
|
||||||
subdomain: 'ecole-alpha',
|
subdomain: 'ecole-alpha',
|
||||||
|
adminEmail: 'admin@ecole-alpha.fr',
|
||||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||||
));
|
));
|
||||||
@@ -49,6 +50,6 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
|||||||
self::assertCount(1, $result);
|
self::assertCount(1, $result);
|
||||||
self::assertSame('École Alpha', $result[0]->name);
|
self::assertSame('École Alpha', $result[0]->name);
|
||||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||||
self::assertSame('active', $result[0]->status);
|
self::assertSame('provisioning', $result[0]->status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class DatabaseTenantProvisionerTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function itCallsCreatorThenMigratorInOrder(): void
|
||||||
|
{
|
||||||
|
$steps = [];
|
||||||
|
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection->method('fetchOne')->willReturn(false);
|
||||||
|
$connection->method('executeStatement')->willReturnCallback(
|
||||||
|
static function () use (&$steps): int {
|
||||||
|
$steps[] = 'create';
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||||
|
|
||||||
|
// TenantMigrator is final — we wrap via the TenantProvisioner interface
|
||||||
|
// to verify the creator is called. Migration subprocess cannot be tested unitarily.
|
||||||
|
$provisioner = new class($creator, $steps) implements TenantProvisioner {
|
||||||
|
/** @param string[] $steps */
|
||||||
|
public function __construct(
|
||||||
|
private readonly TenantDatabaseCreator $creator,
|
||||||
|
private array &$steps,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provision(string $databaseName): void
|
||||||
|
{
|
||||||
|
$this->creator->create($databaseName);
|
||||||
|
$this->steps[] = 'migrate';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$provisioner->provision('classeo_tenant_test');
|
||||||
|
|
||||||
|
self::assertSame(['create', 'migrate'], $steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPropagatesCreationFailure(): void
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection->method('fetchOne')->willThrowException(new RuntimeException('Connection refused'));
|
||||||
|
|
||||||
|
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||||
|
$migrator = new TenantMigrator('/tmp', 'postgresql://u:p@h/db', new NullLogger());
|
||||||
|
|
||||||
|
$provisioner = new DatabaseTenantProvisioner($creator, $migrator);
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$provisioner->provision('classeo_tenant_test');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||||
|
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
final class ProvisionEstablishmentHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master?serverVersion=18';
|
||||||
|
private const string ESTABLISHMENT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itProvisionsTenantDatabase(): void
|
||||||
|
{
|
||||||
|
$provisioner = $this->createMock(TenantProvisioner::class);
|
||||||
|
$provisioner->expects(self::once())
|
||||||
|
->method('provision')
|
||||||
|
->with('classeo_tenant_abc123');
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(provisioner: $provisioner);
|
||||||
|
$handler($this->command());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesAdminUser(): void
|
||||||
|
{
|
||||||
|
$userRepository = new InMemoryUserRepository();
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(userRepository: $userRepository);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
$users = $userRepository->findAllByTenant(TenantId::fromString(self::TENANT_ID));
|
||||||
|
self::assertCount(1, $users);
|
||||||
|
self::assertSame('admin@ecole-gamma.fr', (string) $users[0]->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itDispatchesInvitationEvent(): void
|
||||||
|
{
|
||||||
|
$dispatched = [];
|
||||||
|
$eventBus = $this->spyEventBus($dispatched);
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(eventBus: $eventBus);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
self::assertNotEmpty($dispatched);
|
||||||
|
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itActivatesEstablishmentAfterProvisioning(): void
|
||||||
|
{
|
||||||
|
$establishmentRepo = $this->establishmentRepoWithProvisioningEstablishment();
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(establishmentRepository: $establishmentRepo);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
$establishment = $establishmentRepo->get(
|
||||||
|
EstablishmentId::fromString(self::ESTABLISHMENT_ID),
|
||||||
|
);
|
||||||
|
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itIsIdempotentWhenAdminAlreadyExists(): void
|
||||||
|
{
|
||||||
|
$userRepository = new InMemoryUserRepository();
|
||||||
|
$dispatched = [];
|
||||||
|
$eventBus = $this->spyEventBus($dispatched);
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(userRepository: $userRepository, eventBus: $eventBus);
|
||||||
|
|
||||||
|
// First call creates the admin
|
||||||
|
$handler($this->command());
|
||||||
|
self::assertCount(1, $dispatched);
|
||||||
|
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
|
||||||
|
|
||||||
|
// Second call is idempotent — re-sends invitation
|
||||||
|
$dispatched = [];
|
||||||
|
$handler($this->command());
|
||||||
|
self::assertCount(1, $dispatched);
|
||||||
|
self::assertInstanceOf(InvitationRenvoyee::class, $dispatched[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itSwitchesDatabaseAndRestores(): void
|
||||||
|
{
|
||||||
|
$switcher = new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(databaseSwitcher: $switcher);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
self::assertCount(1, $switcher->switchedTo);
|
||||||
|
self::assertStringContainsString('classeo_tenant_abc123', $switcher->switchedTo[0]);
|
||||||
|
self::assertTrue($switcher->restoredToDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPreservesQueryParametersInDatabaseUrl(): void
|
||||||
|
{
|
||||||
|
$switcher = new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(databaseSwitcher: $switcher);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
self::assertStringContainsString('?serverVersion=18', $switcher->switchedTo[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itRestoresDatabaseEvenOnFailure(): void
|
||||||
|
{
|
||||||
|
$switcher = new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$eventBus->method('dispatch')
|
||||||
|
->willThrowException(new RuntimeException('Event bus failure'));
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(databaseSwitcher: $switcher, eventBus: $eventBus);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$handler($this->command());
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertTrue($switcher->restoredToDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function command(): ProvisionEstablishmentCommand
|
||||||
|
{
|
||||||
|
return new ProvisionEstablishmentCommand(
|
||||||
|
establishmentId: self::ESTABLISHMENT_ID,
|
||||||
|
establishmentTenantId: self::TENANT_ID,
|
||||||
|
databaseName: 'classeo_tenant_abc123',
|
||||||
|
subdomain: 'ecole-gamma',
|
||||||
|
adminEmail: 'admin@ecole-gamma.fr',
|
||||||
|
establishmentName: 'École Gamma',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function establishmentRepoWithProvisioningEstablishment(): InMemoryEstablishmentRepository
|
||||||
|
{
|
||||||
|
$repo = new InMemoryEstablishmentRepository();
|
||||||
|
$establishment = Establishment::reconstitute(
|
||||||
|
id: EstablishmentId::fromString(self::ESTABLISHMENT_ID),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
name: 'École Gamma',
|
||||||
|
subdomain: 'ecole-gamma',
|
||||||
|
databaseName: 'classeo_tenant_abc123',
|
||||||
|
status: EstablishmentStatus::PROVISIONING,
|
||||||
|
createdAt: new DateTimeImmutable('2026-04-07 10:00:00'),
|
||||||
|
createdBy: SuperAdminId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||||
|
);
|
||||||
|
$repo->save($establishment);
|
||||||
|
|
||||||
|
return $repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param object[] $dispatched
|
||||||
|
*/
|
||||||
|
private function spyEventBus(array &$dispatched): MessageBusInterface
|
||||||
|
{
|
||||||
|
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$eventBus->method('dispatch')
|
||||||
|
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
|
||||||
|
$dispatched[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $eventBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildHandler(
|
||||||
|
?TenantProvisioner $provisioner = null,
|
||||||
|
?InMemoryUserRepository $userRepository = null,
|
||||||
|
?SpyDatabaseSwitcher $databaseSwitcher = null,
|
||||||
|
?InMemoryEstablishmentRepository $establishmentRepository = null,
|
||||||
|
?MessageBusInterface $eventBus = null,
|
||||||
|
): ProvisionEstablishmentHandler {
|
||||||
|
$provisioner ??= $this->createMock(TenantProvisioner::class);
|
||||||
|
|
||||||
|
$clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-04-07 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$userRepository ??= new InMemoryUserRepository();
|
||||||
|
|
||||||
|
$databaseSwitcher ??= new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$establishmentRepository ??= $this->establishmentRepoWithProvisioningEstablishment();
|
||||||
|
|
||||||
|
$eventBus ??= $this->createMock(MessageBusInterface::class);
|
||||||
|
$eventBus->method('dispatch')
|
||||||
|
->willReturnCallback(static fn (object $m): Envelope => new Envelope($m));
|
||||||
|
|
||||||
|
return new ProvisionEstablishmentHandler(
|
||||||
|
tenantProvisioner: $provisioner,
|
||||||
|
inviteUserHandler: new InviteUserHandler($userRepository, $clock),
|
||||||
|
userRepository: $userRepository,
|
||||||
|
clock: $clock,
|
||||||
|
databaseSwitcher: $databaseSwitcher,
|
||||||
|
establishmentRepository: $establishmentRepository,
|
||||||
|
eventBus: $eventBus,
|
||||||
|
logger: new NullLogger(),
|
||||||
|
masterDatabaseUrl: self::MASTER_URL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
|
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests: verify the full provisioning flow from API request
|
||||||
|
* through establishment creation to async provisioning and admin user creation.
|
||||||
|
*
|
||||||
|
* Split into focused tests that each verify one aspect of the flow.
|
||||||
|
*/
|
||||||
|
final class ProvisioningIntegrationTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||||
|
|
||||||
|
private InMemoryEstablishmentRepository $establishmentRepository;
|
||||||
|
private InMemoryUserRepository $userRepository;
|
||||||
|
private ?ProvisionEstablishmentCommand $provisionCommand;
|
||||||
|
/** @var object[] */
|
||||||
|
private array $dispatchedEvents;
|
||||||
|
|
||||||
|
private function runFullFlow(): void
|
||||||
|
{
|
||||||
|
$clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-04-07 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 1: API processor creates establishment
|
||||||
|
$this->establishmentRepository = new InMemoryEstablishmentRepository();
|
||||||
|
$createHandler = new CreateEstablishmentHandler($this->establishmentRepository, $clock);
|
||||||
|
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('getUser')->willReturn(new SecuritySuperAdmin(
|
||||||
|
SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
|
'superadmin@classeo.fr',
|
||||||
|
'hashed',
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->provisionCommand = null;
|
||||||
|
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$commandBus->method('dispatch')
|
||||||
|
->willReturnCallback(function (object $message): Envelope {
|
||||||
|
if ($message instanceof ProvisionEstablishmentCommand) {
|
||||||
|
$this->provisionCommand = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
$processor = new CreateEstablishmentProcessor($createHandler, $security, $commandBus);
|
||||||
|
|
||||||
|
$input = new EstablishmentResource();
|
||||||
|
$input->name = 'École Test';
|
||||||
|
$input->subdomain = 'ecole-test';
|
||||||
|
$input->adminEmail = 'admin@ecole-test.fr';
|
||||||
|
|
||||||
|
$processor->process($input, new Post());
|
||||||
|
|
||||||
|
// Phase 2: Provisioning handler processes the command
|
||||||
|
self::assertNotNull($this->provisionCommand);
|
||||||
|
|
||||||
|
$this->userRepository = new InMemoryUserRepository();
|
||||||
|
$this->dispatchedEvents = [];
|
||||||
|
|
||||||
|
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$eventBus->method('dispatch')
|
||||||
|
->willReturnCallback(function (object $message): Envelope {
|
||||||
|
$this->dispatchedEvents[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
$provisioner = $this->createMock(TenantProvisioner::class);
|
||||||
|
|
||||||
|
$switcher = new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$provisionHandler = new ProvisionEstablishmentHandler(
|
||||||
|
tenantProvisioner: $provisioner,
|
||||||
|
inviteUserHandler: new InviteUserHandler($this->userRepository, $clock),
|
||||||
|
userRepository: $this->userRepository,
|
||||||
|
clock: $clock,
|
||||||
|
databaseSwitcher: $switcher,
|
||||||
|
establishmentRepository: $this->establishmentRepository,
|
||||||
|
eventBus: $eventBus,
|
||||||
|
logger: new NullLogger(),
|
||||||
|
masterDatabaseUrl: self::MASTER_URL,
|
||||||
|
);
|
||||||
|
|
||||||
|
$provisionHandler($this->provisionCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function processorCreatesEstablishmentInProvisioningStatus(): void
|
||||||
|
{
|
||||||
|
$this->runFullFlow();
|
||||||
|
|
||||||
|
$establishments = $this->establishmentRepository->findAll();
|
||||||
|
self::assertCount(1, $establishments);
|
||||||
|
self::assertSame('École Test', $establishments[0]->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function processorDispatchesProvisioningCommandWithAdminEmail(): void
|
||||||
|
{
|
||||||
|
$this->runFullFlow();
|
||||||
|
|
||||||
|
self::assertNotNull($this->provisionCommand);
|
||||||
|
self::assertSame('admin@ecole-test.fr', $this->provisionCommand->adminEmail);
|
||||||
|
self::assertSame('ecole-test', $this->provisionCommand->subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function provisioningCreatesAdminUserWithCorrectRole(): void
|
||||||
|
{
|
||||||
|
$this->runFullFlow();
|
||||||
|
|
||||||
|
$users = $this->userRepository->findAllByTenant(
|
||||||
|
TenantId::fromString($this->provisionCommand->establishmentTenantId),
|
||||||
|
);
|
||||||
|
self::assertCount(1, $users);
|
||||||
|
self::assertSame('admin@ecole-test.fr', (string) $users[0]->email);
|
||||||
|
self::assertSame(Role::ADMIN, $users[0]->role);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function provisioningActivatesEstablishmentAndDispatchesEvent(): void
|
||||||
|
{
|
||||||
|
$this->runFullFlow();
|
||||||
|
|
||||||
|
$establishments = $this->establishmentRepository->findAll();
|
||||||
|
self::assertSame(EstablishmentStatus::ACTIF, $establishments[0]->status);
|
||||||
|
|
||||||
|
self::assertCount(1, $this->dispatchedEvents);
|
||||||
|
self::assertInstanceOf(UtilisateurInvite::class, $this->dispatchedEvents[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test double that records database switching operations.
|
||||||
|
*/
|
||||||
|
final class SpyDatabaseSwitcher implements TenantDatabaseSwitcher
|
||||||
|
{
|
||||||
|
/** @var string[] */
|
||||||
|
public array $switchedTo = [];
|
||||||
|
public bool $restoredToDefault = false;
|
||||||
|
|
||||||
|
public function useTenantDatabase(string $databaseUrl): void
|
||||||
|
{
|
||||||
|
$this->switchedTo[] = $databaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function useDefaultDatabase(): void
|
||||||
|
{
|
||||||
|
$this->restoredToDefault = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentDatabaseUrl(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
{$APP_DOMAIN} {
|
# Domaine principal et sous-domaines wildcard (multi-tenant)
|
||||||
|
# Caddy provisionne automatiquement les certificats TLS via Let's Encrypt.
|
||||||
|
# Le wildcard nécessite un DNS challenge : configurer CADDY_DNS_PROVIDER
|
||||||
|
# et les credentials DNS dans les variables d'environnement.
|
||||||
|
|
||||||
|
{$APP_DOMAIN}, *.{$APP_DOMAIN} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Le certificat wildcard nécessite un DNS challenge
|
||||||
|
tls {
|
||||||
|
dns {$CADDY_DNS_PROVIDER:cloudflare} {$CADDY_DNS_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy php:8000
|
reverse_proxy php:8000
|
||||||
}
|
}
|
||||||
|
|||||||
167
frontend/e2e/role-assignment.spec.ts
Normal file
167
frontend/e2e/role-assignment.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = 'e2e-roles-admin@example.com';
|
||||||
|
const ADMIN_PASSWORD = 'RolesAdmin123';
|
||||||
|
const TARGET_EMAIL = `e2e-roles-target-${Date.now()}@example.com`;
|
||||||
|
const TARGET_PASSWORD = 'RolesTarget123';
|
||||||
|
|
||||||
|
test.describe('Multi-Role Assignment (FR5) [P2]', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create target user with single role (PROF)
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||||
|
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||||
|
page.getByRole('button', { name: /se connecter/i }).click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRolesModalForTarget(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||||
|
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Search for the target user (paginated list may not show them on page 1)
|
||||||
|
await page.getByRole('searchbox').fill(TARGET_EMAIL);
|
||||||
|
await page.waitForTimeout(500); // debounce
|
||||||
|
|
||||||
|
// Find the target user row and click "Rôles" button
|
||||||
|
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
|
||||||
|
await expect(targetRow).toBeVisible({ timeout: 10000 });
|
||||||
|
await targetRow.getByRole('button', { name: 'Rôles' }).click();
|
||||||
|
|
||||||
|
// Modal should appear
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.locator('#roles-modal-title')).toHaveText('Modifier les rôles');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('[P2] admin can open role modal showing current roles', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Target user email should be displayed in modal
|
||||||
|
await expect(page.locator('.roles-modal-user')).toContainText(TARGET_EMAIL);
|
||||||
|
|
||||||
|
// ROLE_PROF should be checked (current role)
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
// Other roles should be unchecked
|
||||||
|
const adminCheckbox = page.locator('.role-checkbox-label', { hasText: 'Directeur' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(adminCheckbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] admin can assign multiple roles to a user', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Add Vie Scolaire role in addition to PROF
|
||||||
|
const vieScolaireLabel = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' });
|
||||||
|
await vieScolaireLabel.locator('input[type="checkbox"]').check();
|
||||||
|
|
||||||
|
// Both should now be checked
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
await expect(vieScolaireLabel.locator('input[type="checkbox"]')).toBeChecked();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const saveResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||||
|
const saveResponse = await saveResponsePromise;
|
||||||
|
expect(saveResponse.status()).toBeLessThan(400);
|
||||||
|
|
||||||
|
// Success message should appear
|
||||||
|
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] assigned roles persist after page reload', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Both PROF and VIE_SCOLAIRE should still be checked after reload
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||||
|
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
await expect(vieScolaireCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] admin can remove a role while keeping at least one', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Uncheck Vie Scolaire (added in previous test)
|
||||||
|
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||||
|
await vieScolaireCheckbox.uncheck();
|
||||||
|
|
||||||
|
// PROF should still be checked
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
await expect(vieScolaireCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const saveResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||||
|
await saveResponsePromise;
|
||||||
|
|
||||||
|
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] last role checkbox is disabled to prevent removal', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Only PROF should be checked now (after previous test removed VIE_SCOLAIRE)
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
// Last role checkbox should be disabled
|
||||||
|
await expect(profCheckbox).toBeDisabled();
|
||||||
|
|
||||||
|
// "(dernier rôle)" hint should be visible
|
||||||
|
await expect(
|
||||||
|
page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('.role-checkbox-hint')
|
||||||
|
).toContainText('dernier rôle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] role modal can be closed with Escape', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
await page.getByRole('dialog').press('Escape');
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const SA_PASSWORD = 'SuperAdmin123';
|
||||||
|
const UNIQUE_SUFFIX = Date.now();
|
||||||
|
|
||||||
|
function getSuperAdminEmail(browserName: string): string {
|
||||||
|
return `e2e-prov-sa-${browserName}@test.com`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-empty-pattern
|
||||||
|
test.beforeAll(async ({}, testInfo) => {
|
||||||
|
const browserName = testInfo.project.name;
|
||||||
|
const saEmail = getSuperAdminEmail(browserName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-super-admin --email=${saEmail} --password=${SA_PASSWORD} 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${browserName}] Failed to create super admin:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAsSuperAdmin(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
email: string
|
||||||
|
) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('#email').fill(email);
|
||||||
|
await page.locator('#password').fill(SA_PASSWORD);
|
||||||
|
|
||||||
|
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/super-admin/dashboard', { timeout: 30000 }),
|
||||||
|
submitButton.click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToEstablishments(page: import('@playwright/test').Page) {
|
||||||
|
const link = page.getByRole('link', { name: /établissements/i });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||||
|
link.click()
|
||||||
|
]);
|
||||||
|
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToCreateForm(page: import('@playwright/test').Page) {
|
||||||
|
const newLink = page.getByRole('link', { name: /nouvel établissement/i });
|
||||||
|
await expect(newLink).toBeVisible({ timeout: 10000 });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/super-admin/establishments/new', { timeout: 10000 }),
|
||||||
|
newLink.click()
|
||||||
|
]);
|
||||||
|
await expect(page.locator('#name')).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Establishment Provisioning (Story 2-17) [P1]', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Subdomain Auto-generation', () => {
|
||||||
|
test('[P1] typing name auto-generates subdomain', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
await page.locator('#name').fill('École Saint-Exupéry');
|
||||||
|
|
||||||
|
// Subdomain should be auto-generated: accents removed, spaces→hyphens, lowercase
|
||||||
|
await expect(page.locator('#subdomain')).toHaveValue('ecole-saint-exupery');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] subdomain suffix .classeo.fr is displayed', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
await expect(page.locator('.subdomain-suffix')).toHaveText('.classeo.fr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Create Establishment Flow', () => {
|
||||||
|
const establishmentName = `E2E Test ${UNIQUE_SUFFIX}`;
|
||||||
|
const adminEmailForEstab = `admin-prov-${UNIQUE_SUFFIX}@test.com`;
|
||||||
|
|
||||||
|
test('[P1] submitting form creates establishment and redirects to list', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
// Fill in the form
|
||||||
|
await page.locator('#name').fill(establishmentName);
|
||||||
|
await page.locator('#adminEmail').fill(adminEmailForEstab);
|
||||||
|
|
||||||
|
// Subdomain should be auto-generated
|
||||||
|
const subdomain = await page.locator('#subdomain').inputValue();
|
||||||
|
expect(subdomain.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
|
||||||
|
const apiResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/super-admin/establishments') && resp.request().method() === 'POST'
|
||||||
|
);
|
||||||
|
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Verify API returns establishment in provisioning status
|
||||||
|
const apiResponse = await apiResponsePromise;
|
||||||
|
expect(apiResponse.status()).toBeLessThan(400);
|
||||||
|
const body = await apiResponse.json();
|
||||||
|
expect(body.status).toBe('provisioning');
|
||||||
|
|
||||||
|
// Should redirect back to establishments list
|
||||||
|
await page.waitForURL('**/super-admin/establishments', { timeout: 15000 });
|
||||||
|
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P1] created establishment appears in the list', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
|
||||||
|
// The establishment created in previous test should be visible
|
||||||
|
await expect(page.locator('table')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('td', { hasText: establishmentName })).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P1] created establishment has a visible status badge', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
|
||||||
|
// Find the row for our establishment
|
||||||
|
const row = page.locator('tr', { has: page.locator(`text=${establishmentName}`) });
|
||||||
|
await expect(row).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Status badge should be visible (provisioning status already verified via API response in creation test)
|
||||||
|
const badge = row.locator('.badge');
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).not.toHaveText('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Form Validation', () => {
|
||||||
|
test('[P2] submit button disabled with empty fields', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] submit button enabled when all fields filled', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
await page.locator('#name').fill('Test School');
|
||||||
|
await page.locator('#adminEmail').fill('admin@test.com');
|
||||||
|
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] cancel button returns to establishments list', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
const cancelLink = page.getByRole('link', { name: /annuler/i });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||||
|
cancelLink.click()
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,8 +71,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="subdomain-cell">{establishment.subdomain}</td>
|
<td class="subdomain-cell">{establishment.subdomain}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" class:active={establishment.status === 'active'}>
|
<span
|
||||||
{establishment.status === 'active' ? 'Actif' : 'Inactif'}
|
class="badge"
|
||||||
|
class:active={establishment.status === 'active'}
|
||||||
|
class:provisioning={establishment.status === 'provisioning'}
|
||||||
|
>
|
||||||
|
{establishment.status === 'active'
|
||||||
|
? 'Actif'
|
||||||
|
: establishment.status === 'provisioning'
|
||||||
|
? 'Provisioning…'
|
||||||
|
: 'Inactif'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="date-cell">
|
<td class="date-cell">
|
||||||
@@ -207,6 +215,11 @@
|
|||||||
color: #16a34a;
|
color: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge.provisioning {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
.actions-cell {
|
.actions-cell {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user