feat: Provisionner automatiquement un nouvel établissement
Some checks failed
CI / Naming Conventions (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-04-08 13:55:41 +02:00
parent bec211ebf0
commit 713e408773
65 changed files with 5070 additions and 374 deletions

View File

@@ -89,6 +89,14 @@ TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_FAIL_OPEN=true
###< cloudflare/turnstile ###
###> s3/minio ###
S3_ENDPOINT=http://minio:9000
S3_BUCKET=classeo
S3_KEY=classeo
S3_SECRET=classeo_secret
S3_REGION=us-east-1
###< s3/minio ###
###> symfony/lock ###
# Choose one of the stores below
# postgresql+advisory://db_user:db_password@localhost/db_name

View File

@@ -15,6 +15,7 @@
"doctrine/doctrine-bundle": "^2.13 || ^3.0@dev",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"league/flysystem-aws-s3-v3": "^3.32",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"phpoffice/phpspreadsheet": "^5.4",
@@ -26,6 +27,7 @@
"symfony/console": "^8.0",
"symfony/doctrine-messenger": "^8.0",
"symfony/dotenv": "^8.0",
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "^8.0",
"symfony/html-sanitizer": "8.0.*",

790
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "92b9472c96a59c314d96372c4094f185",
"content-hash": "851abcf008c69423a69ad329ae88a255",
"packages": [
{
"name": "api-platform/core",
@@ -224,6 +224,157 @@
},
"time": "2026-01-23T15:23:18+00:00"
},
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.378.0",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "7a95e0665ad13c2cb8999d64439cf969c86724dd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7a95e0665ad13c2cb8999d64439cf969c86724dd",
"reference": "7a95e0665ad13c2cb8999d64439cf969c86724dd",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/promises": "^2.0",
"guzzlehttp/psr7": "^2.4.5",
"mtdowling/jmespath.php": "^2.8.0",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.378.0"
},
"time": "2026-04-08T18:13:19+00:00"
},
{
"name": "brick/math",
"version": "0.14.1",
@@ -1536,6 +1687,215 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "7.10.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^2.3",
"guzzlehttp/psr7": "^2.8",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0"
},
"provide": {
"psr/http-client-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.39 || ^9.6.20",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
"ext-curl": "Required for CURL handler support",
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"GuzzleHttp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Jeremy Lindblom",
"email": "jeremeamia@gmail.com",
"homepage": "https://github.com/jeremeamia"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle is a PHP HTTP client library",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"psr-18",
"psr-7",
"rest",
"web service"
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.10.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
"type": "tidelift"
}
],
"time": "2025-08-23T22:36:01+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "481557b130ef3790cf82b713667b43030dc9c957"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
"reference": "481557b130ef3790cf82b713667b43030dc9c957",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.3.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
"type": "tidelift"
}
],
"time": "2025-08-22T14:34:08+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.8.0",
@@ -1785,6 +2145,249 @@
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "league/flysystem",
"version": "3.33.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
"reference": "570b8871e0ce693764434b29154c54b434905350"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350",
"reference": "570b8871e0ce693764434b29154c54b434905350",
"shasum": ""
},
"require": {
"league/flysystem-local": "^3.0.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"async-aws/core": "<1.19.0",
"async-aws/s3": "<1.14.0",
"aws/aws-sdk-php": "3.209.31 || 3.210.0",
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1",
"phpseclib/phpseclib": "3.0.15",
"symfony/http-client": "<5.2"
},
"require-dev": {
"async-aws/s3": "^1.5 || ^2.0",
"async-aws/simple-s3": "^1.1 || ^2.0",
"aws/aws-sdk-php": "^3.295.10",
"composer/semver": "^3.0",
"ext-fileinfo": "*",
"ext-ftp": "*",
"ext-mongodb": "^1.3|^2",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.5",
"google/cloud-storage": "^1.23",
"guzzlehttp/psr7": "^2.6",
"microsoft/azure-storage-blob": "^1.1",
"mongodb/mongodb": "^1.2|^2",
"phpseclib/phpseclib": "^3.0.36",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.5.11|^10.0",
"sabre/dav": "^4.6.0"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "File storage abstraction for PHP",
"keywords": [
"WebDAV",
"aws",
"cloud",
"file",
"files",
"filesystem",
"filesystems",
"ftp",
"s3",
"sftp",
"storage"
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.33.0"
},
"time": "2026-03-25T07:59:30+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.32.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0",
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.295.10",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0"
},
"time": "2026-02-25T16:46:44+00:00"
},
{
"name": "league/flysystem-local",
"version": "3.31.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-local.git",
"reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079",
"reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"league/flysystem": "^3.0.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\Local\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "Local filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"file",
"files",
"filesystem",
"local"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0"
},
"time": "2026-01-23T15:30:45+00:00"
},
{
"name": "league/mime-type-detection",
"version": "1.16.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/mime-type-detection.git",
"reference": "2d6702ff215bf922936ccc1ad31007edc76451b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9",
"reference": "2d6702ff215bf922936ccc1ad31007edc76451b9",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"phpstan/phpstan": "^0.12.68",
"phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\MimeTypeDetection\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "Mime-type detection for Flysystem",
"support": {
"issues": "https://github.com/thephpleague/mime-type-detection/issues",
"source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0"
},
"funding": [
{
"url": "https://github.com/frankdejonge",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/flysystem",
"type": "tidelift"
}
],
"time": "2024-09-21T08:32:55+00:00"
},
{
"name": "league/uri",
"version": "7.8.1",
@@ -2371,6 +2974,72 @@
],
"time": "2026-01-02T08:56:05+00:00"
},
{
"name": "mtdowling/jmespath.php",
"version": "2.8.0",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.33"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.8-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.8.0"
},
"time": "2024-09-04T18:46:31+00:00"
},
{
"name": "nelmio/cors-bundle",
"version": "2.6.1",
@@ -2813,6 +3482,58 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{
"name": "psr/http-factory",
"version": "1.1.0",
@@ -4672,6 +5393,73 @@
],
"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",
"version": "v8.0.1",

View File

@@ -64,3 +64,5 @@ framework:
# Import élèves/enseignants → async (batch processing, peut être long)
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async
# Provisioning établissement → async (création BDD, migrations, premier admin)
App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand: async

View File

@@ -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 :
# 1. Via la variable d'environnement TENANT_CONFIGS (JSON)
# 2. Via une implémentation DatabaseTenantRegistry (à implémenter)
#
# 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)%'
# Le DoctrineTenantRegistry interroge la table establishments sur la base master.
# Les nouveaux établissements sont immédiatement accessibles via leur sous-domaine
# sans redémarrage de l'application.
services:
App\Shared\Infrastructure\Tenant\TenantRegistry:
class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry
factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromEnv']
App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry:
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

View File

@@ -247,12 +247,20 @@ services:
$homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer'
App\Scolarite\Application\Port\FileStorage:
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage
alias: App\Scolarite\Infrastructure\Storage\S3FileStorage
App\Scolarite\Infrastructure\Storage\LocalFileStorage:
arguments:
$storagePath: '%kernel.project_dir%/var/storage'
App\Scolarite\Infrastructure\Storage\S3FileStorage:
arguments:
$endpoint: '%env(S3_ENDPOINT)%'
$bucket: '%env(S3_BUCKET)%'
$key: '%env(S3_KEY)%'
$secret: '%env(S3_SECRET)%'
$region: '%env(S3_REGION)%'
# Schedule (Story 4.1 - Emploi du temps)
App\Scolarite\Domain\Repository\ScheduleSlotRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository
@@ -333,6 +341,23 @@ services:
App\SuperAdmin\Domain\Repository\EstablishmentRepository:
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)
App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository

View File

@@ -12,4 +12,9 @@ interface FileStorage
public function upload(string $path, mixed $content, string $mimeType): string;
public function delete(string $path): void;
/**
* @return resource
*/
public function readStream(string $path): mixed;
}

View File

@@ -6,22 +6,26 @@ namespace App\Scolarite\Application\Query\GetBlockedDates;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Scolarite\Application\Port\HomeworkRulesChecker;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateInterval;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends)
* pour une plage de dates donnée.
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends,
* et dates non conformes aux règles de devoirs) pour une plage de dates donnée.
*
* Utilisé par le frontend pour griser les jours non modifiables dans la grille EDT.
* Utilisé par le frontend pour griser les jours non disponibles dans le calendrier.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetBlockedDatesHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private HomeworkRulesChecker $rulesChecker,
private Clock $clock,
) {
}
@@ -37,6 +41,7 @@ final readonly class GetBlockedDatesHandler
$endDate = new DateTimeImmutable($query->endDate);
$oneDay = new DateInterval('P1D');
$now = $this->clock->now();
$blockedDates = [];
$current = $startDate;
@@ -50,14 +55,21 @@ final readonly class GetBlockedDatesHandler
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
type: 'weekend',
);
} elseif ($calendar !== null) {
$entry = $calendar->trouverEntreePourDate($current);
} elseif ($calendar !== null && ($entry = $calendar->trouverEntreePourDate($current)) !== null) {
$blockedDates[] = new BlockedDateDto(
date: $dateStr,
reason: $entry->label,
type: $entry->type->value,
);
} else {
$dueDate = new DateTimeImmutable($dateStr);
$result = $this->rulesChecker->verifier($tenantId, $dueDate, $now);
if ($entry !== null) {
if (!$result->estValide()) {
$blockedDates[] = new BlockedDateDto(
date: $dateStr,
reason: $entry->label,
type: $entry->type->value,
reason: $result->messages()[0] ?? 'Règle de devoirs',
type: $result->estBloquant() ? 'rule_hard' : 'rule_soft',
);
}
}

View File

@@ -16,16 +16,16 @@ use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function realpath;
use function str_starts_with;
use function fclose;
use function fpassthru;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -39,8 +39,6 @@ final readonly class HomeworkAttachmentController
private HomeworkAttachmentRepository $attachmentRepository,
private UploadHomeworkAttachmentHandler $uploadHandler,
private FileStorage $fileStorage,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $storageDir,
) {
}
@@ -124,7 +122,7 @@ final readonly class HomeworkAttachmentController
}
#[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_download', methods: ['GET'])]
public function download(string $id, string $attachmentId): BinaryFileResponse
public function download(string $id, string $attachmentId): StreamedResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
@@ -143,20 +141,29 @@ final readonly class HomeworkAttachmentController
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->storageDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realStorageDir = realpath($this->storageDir);
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
try {
$stream = $this->fileStorage->readStream($attachment->filePath);
} catch (RuntimeException) {
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
$response = new BinaryFileResponse($realPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$response = new StreamedResponse(static function () use ($stream): void {
try {
fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename,
);
$response->headers->set('Content-Type', $attachment->mimeType);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Length', (string) $attachment->fileSize);
return $response;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Application\Query\GetChildrenHomework\ChildHomeworkDto;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler;
@@ -18,16 +19,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkParentVoter;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function fclose;
use function fpassthru;
use function is_string;
use function realpath;
use function str_starts_with;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
@@ -45,8 +46,7 @@ final readonly class ParentHomeworkController
private GetChildrenHomeworkDetailHandler $detailHandler,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $uploadsDir,
private FileStorage $fileStorage,
) {
}
@@ -116,7 +116,7 @@ final readonly class ParentHomeworkController
* Téléchargement d'une pièce jointe (parent).
*/
#[Route('/api/me/children/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_parent_child_homework_attachment', methods: ['GET'])]
public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse
public function downloadAttachment(string $homeworkId, string $attachmentId): StreamedResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
@@ -138,20 +138,29 @@ final readonly class ParentHomeworkController
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realUploadsDir = realpath($this->uploadsDir);
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
try {
$stream = $this->fileStorage->readStream($attachment->filePath);
} catch (RuntimeException) {
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
$response = new BinaryFileResponse($realPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$response = new StreamedResponse(static function () use ($stream): void {
try {
fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename,
);
$response->headers->set('Content-Type', $attachment->mimeType);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Length', (string) $attachment->fileSize);
return $response;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler;
@@ -19,16 +20,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkStudentVoter;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function fclose;
use function fpassthru;
use function is_string;
use function realpath;
use function str_starts_with;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
@@ -44,8 +45,7 @@ final readonly class StudentHomeworkController
private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader,
private StudentClassReader $studentClassReader,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $uploadsDir,
private FileStorage $fileStorage,
) {
}
@@ -98,7 +98,7 @@ final readonly class StudentHomeworkController
}
#[Route('/api/me/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_student_homework_attachment', methods: ['GET'])]
public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse
public function downloadAttachment(string $homeworkId, string $attachmentId): StreamedResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
@@ -115,20 +115,29 @@ final readonly class StudentHomeworkController
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realUploadsDir = realpath($this->uploadsDir);
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
try {
$stream = $this->fileStorage->readStream($attachment->filePath);
} catch (RuntimeException) {
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
$response = new BinaryFileResponse($realPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$response = new StreamedResponse(static function () use ($stream): void {
try {
fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename,
);
$response->headers->set('Content-Type', $attachment->mimeType);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Length', (string) $attachment->fileSize);
return $response;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\ClassStudentsReader;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
@@ -24,15 +25,15 @@ use function count;
use DateTimeImmutable;
use function fclose;
use function fpassthru;
use function in_array;
use function realpath;
use function str_starts_with;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
@@ -45,8 +46,7 @@ final readonly class TeacherSubmissionController
private HomeworkSubmissionRepository $submissionRepository,
private SubmissionAttachmentRepository $attachmentRepository,
private ClassStudentsReader $classStudentsReader,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $storageDir,
private FileStorage $fileStorage,
) {
}
@@ -240,7 +240,7 @@ final readonly class TeacherSubmissionController
}
#[Route('/api/homework/{homeworkId}/submissions/{submissionId}/attachments/{attachmentId}', name: 'api_teacher_submission_attachment_download', methods: ['GET'])]
public function downloadAttachment(string $homeworkId, string $submissionId, string $attachmentId): BinaryFileResponse
public function downloadAttachment(string $homeworkId, string $submissionId, string $attachmentId): StreamedResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
@@ -268,20 +268,29 @@ final readonly class TeacherSubmissionController
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->storageDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realStorageDir = realpath($this->storageDir);
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
try {
$stream = $this->fileStorage->readStream($attachment->filePath);
} catch (RuntimeException) {
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
$response = new BinaryFileResponse($realPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$response = new StreamedResponse(static function () use ($stream): void {
try {
fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename,
);
$response->headers->set('Content-Type', $attachment->mimeType);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Length', (string) $attachment->fileSize);
return $response;
}
}

View File

@@ -8,6 +8,7 @@ use App\Scolarite\Application\Port\FileStorage;
use function dirname;
use function file_put_contents;
use function fopen;
use function is_dir;
use function is_file;
use function is_string;
@@ -15,6 +16,12 @@ use function mkdir;
use Override;
use function realpath;
use RuntimeException;
use function sprintf;
use function str_starts_with;
use function unlink;
final readonly class LocalFileStorage implements FileStorage
@@ -50,4 +57,24 @@ final readonly class LocalFileStorage implements FileStorage
unlink($fullPath);
}
}
#[Override]
public function readStream(string $path): mixed
{
$fullPath = $this->storagePath . '/' . $path;
$realPath = realpath($fullPath);
$realStoragePath = realpath($this->storagePath);
if ($realPath === false || $realStoragePath === false || !str_starts_with($realPath, $realStoragePath)) {
throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path));
}
$stream = fopen($realPath, 'r');
if ($stream === false) {
throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path));
}
return $stream;
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Storage;
use App\Scolarite\Application\Port\FileStorage;
use Aws\S3\S3Client;
use function is_resource;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\Filesystem;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToReadFile;
use Override;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use RuntimeException;
use function sprintf;
final readonly class S3FileStorage implements FileStorage
{
private Filesystem $filesystem;
private LoggerInterface $logger;
public function __construct(
string $endpoint,
string $bucket,
string $key,
string $secret,
string $region,
?LoggerInterface $logger = null,
) {
$this->logger = $logger ?? new NullLogger();
$client = new S3Client([
'endpoint' => $endpoint,
'credentials' => [
'key' => $key,
'secret' => $secret,
],
'region' => $region,
'version' => 'latest',
'use_path_style_endpoint' => true,
]);
$this->filesystem = new Filesystem(
new AwsS3V3Adapter($client, $bucket),
);
}
#[Override]
public function upload(string $path, mixed $content, string $mimeType): string
{
$config = [
'ContentType' => $mimeType,
];
if (is_resource($content)) {
$this->filesystem->writeStream($path, $content, $config);
} else {
$this->filesystem->write($path, (string) $content, $config);
}
return $path;
}
#[Override]
public function delete(string $path): void
{
try {
$this->filesystem->delete($path);
} catch (UnableToDeleteFile $e) {
$this->logger->warning('S3 delete failed, possible orphan blob: {path}', [
'path' => $path,
'error' => $e->getMessage(),
]);
}
}
#[Override]
public function readStream(string $path): mixed
{
try {
return $this->filesystem->readStream($path);
} catch (UnableToReadFile $e) {
throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path), 0, $e);
}
}
}

View File

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

View File

@@ -17,23 +17,18 @@ final readonly class CreateEstablishmentHandler
) {
}
public function __invoke(CreateEstablishmentCommand $command): CreateEstablishmentResult
public function __invoke(CreateEstablishmentCommand $command): Establishment
{
$establishment = Establishment::creer(
name: $command->name,
subdomain: $command->subdomain,
adminEmail: $command->adminEmail,
createdBy: SuperAdminId::fromString($command->superAdminId),
createdAt: $this->clock->now(),
);
$this->establishmentRepository->save($establishment);
return new CreateEstablishmentResult(
establishmentId: (string) $establishment->id,
tenantId: (string) $establishment->tenantId,
name: $establishment->name,
subdomain: $establishment->subdomain,
databaseName: $establishment->databaseName,
);
return $establishment;
}
}

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ final readonly class EtablissementCree implements DomainEvent
public TenantId $tenantId,
public string $name,
public string $subdomain,
public string $adminEmail,
private DateTimeImmutable $occurredOn,
) {
}

View File

@@ -38,6 +38,7 @@ final class Establishment extends AggregateRoot
public static function creer(
string $name,
string $subdomain,
string $adminEmail,
SuperAdminId $createdBy,
DateTimeImmutable $createdAt,
): self {
@@ -49,7 +50,7 @@ final class Establishment extends AggregateRoot
name: $name,
subdomain: $subdomain,
databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)),
status: EstablishmentStatus::ACTIF,
status: EstablishmentStatus::PROVISIONING,
createdAt: $createdAt,
createdBy: $createdBy,
);
@@ -59,12 +60,18 @@ final class Establishment extends AggregateRoot
tenantId: $establishment->tenantId,
name: $name,
subdomain: $subdomain,
adminEmail: $adminEmail,
occurredOn: $createdAt,
));
return $establishment;
}
public function activer(): void
{
$this->status = EstablishmentStatus::ACTIF;
}
public function desactiver(DateTimeImmutable $at): void
{
if ($this->status !== EstablishmentStatus::ACTIF) {

View File

@@ -6,6 +6,7 @@ namespace App\SuperAdmin\Domain\Model\Establishment;
enum EstablishmentStatus: string
{
case PROVISIONING = 'provisioning';
case ACTIF = 'active';
case INACTIF = 'inactive';
}

View File

@@ -8,10 +8,12 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
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\Security\SecuritySuperAdmin;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @implements ProcessorInterface<EstablishmentResource, EstablishmentResource>
@@ -21,6 +23,7 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
public function __construct(
private CreateEstablishmentHandler $handler,
private Security $security,
private MessageBusInterface $commandBus,
) {
}
@@ -33,20 +36,29 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
/** @var SecuritySuperAdmin $user */
$user = $this->security->getUser();
$result = ($this->handler)(new CreateEstablishmentCommand(
$establishment = ($this->handler)(new CreateEstablishmentCommand(
name: $data->name,
subdomain: $data->subdomain,
adminEmail: $data->adminEmail,
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->id = $result->establishmentId;
$resource->tenantId = $result->tenantId;
$resource->name = $result->name;
$resource->subdomain = $result->subdomain;
$resource->databaseName = $result->databaseName;
$resource->status = 'active';
$resource->id = (string) $establishment->id;
$resource->tenantId = (string) $establishment->tenantId;
$resource->name = $establishment->name;
$resource->subdomain = $establishment->subdomain;
$resource->databaseName = $establishment->databaseName;
$resource->status = $establishment->status->value;
return $resource;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,9 @@ use function sprintf;
use Symfony\Component\HttpClient\HttpClient;
use function sys_get_temp_dir;
use Throwable;
use function unlink;
/**
@@ -42,6 +45,16 @@ final class GouvFrCalendarApiTest extends TestCase
protected function setUp(): void
{
// Skip si l'API externe est injoignable (timeout réseau, DNS, etc.)
try {
$check = HttpClient::create()->request('GET', 'https://data.education.gouv.fr', [
'timeout' => 5,
]);
$check->getStatusCode();
} catch (Throwable) {
self::markTestSkipped('API data.education.gouv.fr injoignable — test ignoré.');
}
$this->tempDir = sys_get_temp_dir() . '/classeo-calendar-test-' . uniqid();
mkdir($this->tempDir);
@@ -55,6 +68,10 @@ final class GouvFrCalendarApiTest extends TestCase
protected function tearDown(): void
{
if (!isset($this->tempDir) || !is_dir($this->tempDir)) {
return;
}
// Supprimer les fichiers générés
$files = glob($this->tempDir . '/*.json');
foreach ($files as $file) {

View File

@@ -112,6 +112,14 @@ final class UploadSubmissionAttachmentHandlerTest extends TestCase
public function delete(string $path): void
{
}
public function readStream(string $path): mixed
{
/** @var resource $stream */
$stream = fopen('php://memory', 'r+');
return $stream;
}
};
$clock = new class implements Clock {

View File

@@ -10,8 +10,11 @@ use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
use App\Scolarite\Application\Port\HomeworkRulesChecker;
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler;
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
@@ -28,7 +31,29 @@ final class GetBlockedDatesHandlerTest extends TestCase
protected function setUp(): void
{
$this->calendarRepository = new InMemorySchoolCalendarRepository();
$this->handler = new GetBlockedDatesHandler($this->calendarRepository);
$rulesChecker = new class implements HomeworkRulesChecker {
public function verifier(
TenantId $tenantId,
DateTimeImmutable $dueDate,
DateTimeImmutable $creationDate,
): HomeworkRulesCheckResult {
return HomeworkRulesCheckResult::ok();
}
};
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-01 10:00:00');
}
};
$this->handler = new GetBlockedDatesHandler(
$this->calendarRepository,
$rulesChecker,
$clock,
);
}
#[Test]
@@ -110,6 +135,93 @@ final class GetBlockedDatesHandlerTest extends TestCase
self::assertCount(5, $vacations);
}
#[Test]
public function returnsRuleHardBlockedDates(): void
{
$rulesChecker = new class implements HomeworkRulesChecker {
public function verifier(
TenantId $tenantId,
DateTimeImmutable $dueDate,
DateTimeImmutable $creationDate,
): HomeworkRulesCheckResult {
// Block Tuesday March 3
if ($dueDate->format('Y-m-d') === '2026-03-03') {
return new HomeworkRulesCheckResult(
warnings: [new \App\Scolarite\Application\Port\RuleWarning('minimum_delay', 'Délai minimum non respecté')],
bloquant: true,
);
}
return HomeworkRulesCheckResult::ok();
}
};
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-01 10:00:00');
}
};
$handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock);
$result = ($handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-06',
));
$ruleBlocked = array_filter($result, static fn ($d) => $d->type === 'rule_hard');
self::assertCount(1, $ruleBlocked);
$blocked = array_values($ruleBlocked)[0];
self::assertSame('2026-03-03', $blocked->date);
self::assertSame('Délai minimum non respecté', $blocked->reason);
}
#[Test]
public function returnsRuleSoftWarningDates(): void
{
$rulesChecker = new class implements HomeworkRulesChecker {
public function verifier(
TenantId $tenantId,
DateTimeImmutable $dueDate,
DateTimeImmutable $creationDate,
): HomeworkRulesCheckResult {
if ($dueDate->format('Y-m-d') === '2026-03-04') {
return new HomeworkRulesCheckResult(
warnings: [new \App\Scolarite\Application\Port\RuleWarning('no_monday_after', 'Devoirs pour lundi déconseillés')],
bloquant: false,
);
}
return HomeworkRulesCheckResult::ok();
}
};
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-01 10:00:00');
}
};
$handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock);
$result = ($handler)(new GetBlockedDatesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
startDate: '2026-03-02',
endDate: '2026-03-06',
));
$ruleSoft = array_filter($result, static fn ($d) => $d->type === 'rule_soft');
self::assertCount(1, $ruleSoft);
$soft = array_values($ruleSoft)[0];
self::assertSame('2026-03-04', $soft->date);
self::assertSame('rule_soft', $soft->type);
}
private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar
{
$tenantId = TenantId::fromString(self::TENANT_ID);

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentHandler;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Infrastructure\Api\Controller\HomeworkAttachmentController;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkAttachmentRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Tests\Unit\Scolarite\Infrastructure\Storage\InMemoryFileStorage;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class HomeworkAttachmentControllerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string OTHER_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryHomeworkRepository $homeworkRepository;
private InMemoryHomeworkAttachmentRepository $attachmentRepository;
private InMemoryFileStorage $fileStorage;
protected function setUp(): void
{
$this->homeworkRepository = new InMemoryHomeworkRepository();
$this->attachmentRepository = new InMemoryHomeworkAttachmentRepository();
$this->fileStorage = new InMemoryFileStorage();
}
#[Test]
public function downloadReturnsStreamedResponseForExistingAttachment(): void
{
$homework = $this->createHomework();
$this->homeworkRepository->save($homework);
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
$this->attachmentRepository->save($homework->id, $attachment);
$this->fileStorage->upload('homework/files/exercices.pdf', 'PDF content here', 'application/pdf');
$controller = $this->createController(self::TEACHER_ID);
$response = $controller->download((string) $homework->id, (string) $attachment->id);
self::assertInstanceOf(StreamedResponse::class, $response);
self::assertSame(200, $response->getStatusCode());
self::assertSame('application/pdf', $response->headers->get('Content-Type'));
self::assertStringContainsString('exercices.pdf', $response->headers->get('Content-Disposition') ?? '');
}
#[Test]
public function downloadReturns404ForNonExistentAttachment(): void
{
$homework = $this->createHomework();
$this->homeworkRepository->save($homework);
$controller = $this->createController(self::TEACHER_ID);
$this->expectException(NotFoundHttpException::class);
$controller->download((string) $homework->id, 'non-existent-attachment-id');
}
#[Test]
public function downloadReturns404WhenFileNotFoundInStorage(): void
{
$homework = $this->createHomework();
$this->homeworkRepository->save($homework);
$attachment = $this->createAttachment('missing.pdf', 'homework/files/missing.pdf');
$this->attachmentRepository->save($homework->id, $attachment);
// File NOT uploaded to storage — simulates a missing blob
$controller = $this->createController(self::TEACHER_ID);
$this->expectException(NotFoundHttpException::class);
$controller->download((string) $homework->id, (string) $attachment->id);
}
#[Test]
public function downloadDeniesAccessToNonOwnerTeacher(): void
{
$homework = $this->createHomework();
$this->homeworkRepository->save($homework);
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
$this->attachmentRepository->save($homework->id, $attachment);
$controller = $this->createController(self::OTHER_TEACHER_ID);
$this->expectException(AccessDeniedHttpException::class);
$controller->download((string) $homework->id, (string) $attachment->id);
}
#[Test]
public function listDeniesAccessToNonOwnerTeacher(): void
{
$homework = $this->createHomework();
$this->homeworkRepository->save($homework);
$controller = $this->createController(self::OTHER_TEACHER_ID);
$this->expectException(AccessDeniedHttpException::class);
$controller->list((string) $homework->id);
}
#[Test]
public function deleteDeniesAccessToNonOwnerTeacher(): void
{
$homework = $this->createHomework();
$this->homeworkRepository->save($homework);
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
$this->attachmentRepository->save($homework->id, $attachment);
$controller = $this->createController(self::OTHER_TEACHER_ID);
$this->expectException(AccessDeniedHttpException::class);
$controller->delete((string) $homework->id, (string) $attachment->id);
}
#[Test]
public function downloadDeniesAccessToUnauthenticatedUser(): void
{
$homework = $this->createHomework();
$this->homeworkRepository->save($homework);
$controller = $this->createControllerWithoutUser();
$this->expectException(AccessDeniedHttpException::class);
$controller->download((string) $homework->id, 'any-attachment-id');
}
#[Test]
public function listReturnsAttachmentsForOwner(): void
{
$homework = $this->createHomework();
$this->homeworkRepository->save($homework);
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
$this->attachmentRepository->save($homework->id, $attachment);
$controller = $this->createController(self::TEACHER_ID);
$response = $controller->list((string) $homework->id);
self::assertSame(200, $response->getStatusCode());
/** @var array<array{id: string, filename: string}> $data */
$data = json_decode((string) $response->getContent(), true);
self::assertCount(1, $data);
self::assertSame('exercices.pdf', $data[0]['filename']);
}
#[Test]
public function deleteRemovesAttachmentAndFile(): void
{
$homework = $this->createHomework();
$this->homeworkRepository->save($homework);
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
$this->attachmentRepository->save($homework->id, $attachment);
$this->fileStorage->upload('homework/files/exercices.pdf', 'content', 'application/pdf');
$controller = $this->createController(self::TEACHER_ID);
$response = $controller->delete((string) $homework->id, (string) $attachment->id);
self::assertSame(204, $response->getStatusCode());
self::assertEmpty($this->attachmentRepository->findByHomeworkId($homework->id));
self::assertFalse($this->fileStorage->has('homework/files/exercices.pdf'));
}
private function createHomework(): Homework
{
return Homework::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
teacherId: UserId::fromString(self::TEACHER_ID),
title: 'Devoir test',
description: 'Description',
dueDate: new DateTimeImmutable('2026-05-01'),
now: new DateTimeImmutable('2026-04-09'),
);
}
private function createAttachment(string $filename, string $filePath): HomeworkAttachment
{
return new HomeworkAttachment(
id: HomeworkAttachmentId::generate(),
filename: $filename,
filePath: $filePath,
fileSize: 5000,
mimeType: 'application/pdf',
uploadedAt: new DateTimeImmutable('2026-04-09'),
);
}
private function createController(string $teacherId): HomeworkAttachmentController
{
$securityUser = new SecurityUser(
userId: UserId::fromString($teacherId),
email: 'teacher@example.com',
hashedPassword: 'hashed',
tenantId: TenantId::fromString(self::TENANT_ID),
roles: ['ROLE_PROF'],
);
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($securityUser);
$uploadHandler = $this->createUploadHandler($this->homeworkRepository, $this->fileStorage);
return new HomeworkAttachmentController(
security: $security,
homeworkRepository: $this->homeworkRepository,
attachmentRepository: $this->attachmentRepository,
uploadHandler: $uploadHandler,
fileStorage: $this->fileStorage,
);
}
private function createControllerWithoutUser(): HomeworkAttachmentController
{
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn(null);
$uploadHandler = $this->createUploadHandler($this->homeworkRepository, $this->fileStorage);
return new HomeworkAttachmentController(
security: $security,
homeworkRepository: $this->homeworkRepository,
attachmentRepository: $this->attachmentRepository,
uploadHandler: $uploadHandler,
fileStorage: $this->fileStorage,
);
}
private function createUploadHandler(HomeworkRepository $homeworkRepository, FileStorage $fileStorage): UploadHomeworkAttachmentHandler
{
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-04-09 10:00:00');
}
};
return new UploadHomeworkAttachmentHandler($homeworkRepository, $fileStorage, $clock);
}
}

View File

@@ -6,10 +6,16 @@ namespace App\Tests\Unit\Scolarite\Infrastructure\Storage;
use App\Scolarite\Application\Port\FileStorage;
use function fopen;
use function fwrite;
use function is_string;
use Override;
use function rewind;
use RuntimeException;
final class InMemoryFileStorage implements FileStorage
{
/** @var array<string, string> */
@@ -29,6 +35,21 @@ final class InMemoryFileStorage implements FileStorage
unset($this->files[$path]);
}
#[Override]
public function readStream(string $path): mixed
{
if (!isset($this->files[$path])) {
throw new RuntimeException("File not found: {$path}");
}
/** @var resource $stream */
$stream = fopen('php://memory', 'r+');
fwrite($stream, $this->files[$path]);
rewind($stream);
return $stream;
}
public function has(string $path): bool
{
return isset($this->files[$path]);

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Storage;
use App\Scolarite\Infrastructure\Storage\S3FileStorage;
use function fopen;
use League\Flysystem\Filesystem;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToReadFile;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use RuntimeException;
final class S3FileStorageTest extends TestCase
{
private Filesystem $filesystem;
private LoggerInterface $logger;
private S3FileStorage $storage;
protected function setUp(): void
{
$this->filesystem = $this->createMock(Filesystem::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->storage = $this->createStorageWithMockedFilesystem($this->filesystem, $this->logger);
}
#[Test]
public function uploadWritesStringContentToFilesystem(): void
{
$this->filesystem->expects(self::once())
->method('write')
->with('homework/abc/file.pdf', 'fake content', ['ContentType' => 'application/pdf']);
$result = $this->storage->upload('homework/abc/file.pdf', 'fake content', 'application/pdf');
self::assertSame('homework/abc/file.pdf', $result);
}
#[Test]
public function uploadWritesStreamContentToFilesystem(): void
{
/** @var resource $stream */
$stream = fopen('php://memory', 'r+');
$this->filesystem->expects(self::once())
->method('writeStream')
->with('homework/abc/file.pdf', $stream, ['ContentType' => 'application/pdf']);
$result = $this->storage->upload('homework/abc/file.pdf', $stream, 'application/pdf');
self::assertSame('homework/abc/file.pdf', $result);
}
#[Test]
public function deleteRemovesFileFromFilesystem(): void
{
$this->filesystem->expects(self::once())
->method('delete')
->with('homework/abc/file.pdf');
$this->logger->expects(self::never())
->method('warning');
$this->storage->delete('homework/abc/file.pdf');
}
#[Test]
public function deleteLogsWarningOnFailure(): void
{
$this->filesystem->expects(self::once())
->method('delete')
->willThrowException(UnableToDeleteFile::atLocation('homework/abc/file.pdf'));
$this->logger->expects(self::once())
->method('warning')
->with(
'S3 delete failed, possible orphan blob: {path}',
self::callback(static fn (array $context): bool => $context['path'] === 'homework/abc/file.pdf'),
);
$this->storage->delete('homework/abc/file.pdf');
}
#[Test]
public function readStreamReturnsResourceFromFilesystem(): void
{
/** @var resource $expectedStream */
$expectedStream = fopen('php://memory', 'r+');
$this->filesystem->expects(self::once())
->method('readStream')
->with('homework/abc/file.pdf')
->willReturn($expectedStream);
$result = $this->storage->readStream('homework/abc/file.pdf');
self::assertSame($expectedStream, $result);
}
#[Test]
public function readStreamThrowsRuntimeExceptionOnMissingFile(): void
{
$this->filesystem->expects(self::once())
->method('readStream')
->with('homework/abc/missing.pdf')
->willThrowException(UnableToReadFile::fromLocation('homework/abc/missing.pdf'));
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Impossible de lire le fichier : homework/abc/missing.pdf');
$this->storage->readStream('homework/abc/missing.pdf');
}
/**
* Creates an S3FileStorage instance with a mocked Filesystem injected via reflection.
*
* S3FileStorage is `final readonly` and its constructor creates a real S3Client,
* so we bypass it with newInstanceWithoutConstructor() and inject mocks directly.
* If the class gains new properties, this method must be updated.
*/
private function createStorageWithMockedFilesystem(Filesystem $filesystem, LoggerInterface $logger): S3FileStorage
{
$reflection = new ReflectionClass(S3FileStorage::class);
$storage = $reflection->newInstanceWithoutConstructor();
$fsProp = $reflection->getProperty('filesystem');
$fsProp->setValue($storage, $filesystem);
$loggerProp = $reflection->getProperty('logger');
$loggerProp->setValue($storage, $logger);
return $storage;
}
}

View File

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

View File

@@ -37,7 +37,7 @@ final class CreateEstablishmentHandlerTest extends TestCase
}
#[Test]
public function createsEstablishmentAndReturnsResult(): void
public function createsEstablishmentAndReturnsIt(): void
{
$command = new CreateEstablishmentCommand(
name: 'École Alpha',
@@ -46,13 +46,13 @@ final class CreateEstablishmentHandlerTest extends TestCase
superAdminId: self::SUPER_ADMIN_ID,
);
$result = ($this->handler)($command);
$establishment = ($this->handler)($command);
self::assertNotEmpty($result->establishmentId);
self::assertNotEmpty($result->tenantId);
self::assertSame('École Alpha', $result->name);
self::assertSame('ecole-alpha', $result->subdomain);
self::assertStringStartsWith('classeo_tenant_', $result->databaseName);
self::assertNotEmpty((string) $establishment->id);
self::assertNotEmpty((string) $establishment->tenantId);
self::assertSame('École Alpha', $establishment->name);
self::assertSame('ecole-alpha', $establishment->subdomain);
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
}
#[Test]
@@ -65,10 +65,10 @@ final class CreateEstablishmentHandlerTest extends TestCase
superAdminId: self::SUPER_ADMIN_ID,
);
$result = ($this->handler)($command);
$establishment = ($this->handler)($command);
$establishments = $this->repository->findAll();
self::assertCount(1, $establishments);
self::assertSame($result->establishmentId, (string) $establishments[0]->id);
self::assertSame((string) $establishment->id, (string) $establishments[0]->id);
}
}

View File

@@ -40,6 +40,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
$this->repository->save(Establishment::creer(
name: 'École Alpha',
subdomain: 'ecole-alpha',
adminEmail: 'admin@ecole-alpha.fr',
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
));
@@ -47,6 +48,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
$this->repository->save(Establishment::creer(
name: 'École Beta',
subdomain: 'ecole-beta',
adminEmail: 'admin@ecole-beta.fr',
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
createdAt: new DateTimeImmutable('2026-02-16 11:00:00'),
));
@@ -56,6 +58,6 @@ final class GetEstablishmentsHandlerTest extends TestCase
self::assertCount(2, $result);
self::assertSame('École Alpha', $result[0]->name);
self::assertSame('ecole-alpha', $result[0]->subdomain);
self::assertSame('active', $result[0]->status);
self::assertSame('provisioning', $result[0]->status);
}
}

View File

@@ -23,11 +23,11 @@ final class EstablishmentTest extends TestCase
private const string SUBDOMAIN = 'ecole-alpha';
#[Test]
public function creerCreatesActiveEstablishment(): void
public function creerCreatesProvisioningEstablishment(): void
{
$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::SUBDOMAIN, $establishment->subdomain);
self::assertNull($establishment->lastActivityAt);
@@ -59,10 +59,21 @@ final class EstablishmentTest extends TestCase
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]
public function desactiverChangesStatusToInactif(): void
{
$establishment = $this->createEstablishment();
$establishment->activer();
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
@@ -73,6 +84,7 @@ final class EstablishmentTest extends TestCase
public function desactiverRecordsEtablissementDesactiveEvent(): void
{
$establishment = $this->createEstablishment();
$establishment->activer();
$establishment->pullDomainEvents(); // Clear creation event
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
@@ -86,6 +98,7 @@ final class EstablishmentTest extends TestCase
public function desactiverThrowsWhenAlreadyInactive(): void
{
$establishment = $this->createEstablishment();
$establishment->activer();
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
$this->expectException(EstablishmentDejaInactifException::class);
@@ -141,6 +154,7 @@ final class EstablishmentTest extends TestCase
return Establishment::creer(
name: self::ESTABLISHMENT_NAME,
subdomain: self::SUBDOMAIN,
adminEmail: 'admin@ecole-alpha.fr',
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
);

View File

@@ -7,6 +7,7 @@ namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Post;
use App\Shared\Domain\Clock;
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
@@ -16,13 +17,15 @@ use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class CreateEstablishmentProcessorTest extends TestCase
{
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
#[Test]
public function processCreatesEstablishmentAndReturnsResource(): void
public function processCreatesEstablishmentAndDispatchesProvisioning(): void
{
$repository = new InMemoryEstablishmentRepository();
$clock = new class implements Clock {
@@ -42,7 +45,16 @@ final class CreateEstablishmentProcessorTest extends TestCase
$security = $this->createMock(Security::class);
$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->name = 'École Gamma';
@@ -55,6 +67,12 @@ final class CreateEstablishmentProcessorTest extends TestCase
self::assertNotNull($result->tenantId);
self::assertSame('École Gamma', $result->name);
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);
}
}

View File

@@ -37,6 +37,7 @@ final class EstablishmentCollectionProviderTest extends TestCase
$repository->save(Establishment::creer(
name: 'École Alpha',
subdomain: 'ecole-alpha',
adminEmail: 'admin@ecole-alpha.fr',
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
));
@@ -49,6 +50,6 @@ final class EstablishmentCollectionProviderTest extends TestCase
self::assertCount(1, $result);
self::assertSame('École Alpha', $result[0]->name);
self::assertSame('ecole-alpha', $result[0]->subdomain);
self::assertSame('active', $result[0]->status);
self::assertSame('provisioning', $result[0]->status);
}
}

View File

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

View File

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

View File

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

View File

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