feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
This commit is contained in:
@@ -74,7 +74,7 @@ development_status:
|
|||||||
2-12b-optimistic-update-pages-admin: done
|
2-12b-optimistic-update-pages-admin: done
|
||||||
2-13-personnalisation-visuelle-etablissement: done
|
2-13-personnalisation-visuelle-etablissement: done
|
||||||
2-15-organisation-sections-dashboard-admin: done
|
2-15-organisation-sections-dashboard-admin: done
|
||||||
2-17-provisioning-automatique-etablissements: ready-for-dev # Tâches post-MVP différées de 2-10
|
2-17-provisioning-automatique-etablissements: done # Tâches post-MVP différées de 2-10
|
||||||
epic-2-retrospective: done
|
epic-2-retrospective: done
|
||||||
|
|
||||||
# Epic 3: Import & Onboarding (5 stories)
|
# Epic 3: Import & Onboarding (5 stories)
|
||||||
@@ -108,7 +108,7 @@ development_status:
|
|||||||
5-8-consultation-des-devoirs-par-le-parent: done
|
5-8-consultation-des-devoirs-par-le-parent: done
|
||||||
5-9-description-enrichie-et-pieces-jointes-enseignant: done
|
5-9-description-enrichie-et-pieces-jointes-enseignant: done
|
||||||
5-10-rendu-de-devoir-par-leleve: done
|
5-10-rendu-de-devoir-par-leleve: done
|
||||||
5-11-description-enrichie-upload-calendrier-devoirs: ready-for-dev # Tâches UX différées de 5-1
|
5-11-description-enrichie-upload-calendrier-devoirs: review # Tâches UX différées de 5-1
|
||||||
epic-5-retrospective: optional
|
epic-5-retrospective: optional
|
||||||
|
|
||||||
# Epic 6: Notes & Évaluations (12 stories)
|
# Epic 6: Notes & Évaluations (12 stories)
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
|||||||
TURNSTILE_FAIL_OPEN=true
|
TURNSTILE_FAIL_OPEN=true
|
||||||
###< cloudflare/turnstile ###
|
###< 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 ###
|
###> symfony/lock ###
|
||||||
# Choose one of the stores below
|
# Choose one of the stores below
|
||||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"doctrine/doctrine-bundle": "^2.13 || ^3.0@dev",
|
"doctrine/doctrine-bundle": "^2.13 || ^3.0@dev",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||||
"doctrine/orm": "^3.3",
|
"doctrine/orm": "^3.3",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.32",
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"phpoffice/phpspreadsheet": "^5.4",
|
"phpoffice/phpspreadsheet": "^5.4",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"symfony/console": "^8.0",
|
"symfony/console": "^8.0",
|
||||||
"symfony/doctrine-messenger": "^8.0",
|
"symfony/doctrine-messenger": "^8.0",
|
||||||
"symfony/dotenv": "^8.0",
|
"symfony/dotenv": "^8.0",
|
||||||
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "^8.0",
|
"symfony/framework-bundle": "^8.0",
|
||||||
"symfony/html-sanitizer": "8.0.*",
|
"symfony/html-sanitizer": "8.0.*",
|
||||||
|
|||||||
790
backend/composer.lock
generated
790
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "92b9472c96a59c314d96372c4094f185",
|
"content-hash": "851abcf008c69423a69ad329ae88a255",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/core",
|
"name": "api-platform/core",
|
||||||
@@ -224,6 +224,157 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-01-23T15:23:18+00:00"
|
"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",
|
"name": "brick/math",
|
||||||
"version": "0.14.1",
|
"version": "0.14.1",
|
||||||
@@ -1536,6 +1687,215 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"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",
|
"name": "guzzlehttp/psr7",
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
@@ -1785,6 +2145,249 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-10-17T11:30:53+00:00"
|
"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",
|
"name": "league/uri",
|
||||||
"version": "7.8.1",
|
"version": "7.8.1",
|
||||||
@@ -2371,6 +2974,72 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-02T08:56:05+00:00"
|
"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",
|
"name": "nelmio/cors-bundle",
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
@@ -2813,6 +3482,58 @@
|
|||||||
},
|
},
|
||||||
"time": "2019-01-08T18:20:26+00:00"
|
"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",
|
"name": "psr/http-factory",
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -4672,6 +5393,73 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-09-25T14:21:43+00:00"
|
"time": "2024-09-25T14:21:43+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/expression-language",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/expression-language.git",
|
||||||
|
"reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/expression-language/zipball/b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b",
|
||||||
|
"reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/cache": "^7.4|^8.0",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\ExpressionLanguage\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides an engine that can compile and evaluate expressions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/expression-language/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/filesystem",
|
"name": "symfony/filesystem",
|
||||||
"version": "v8.0.1",
|
"version": "v8.0.1",
|
||||||
|
|||||||
@@ -64,3 +64,5 @@ framework:
|
|||||||
# Import élèves/enseignants → async (batch processing, peut être long)
|
# Import élèves/enseignants → async (batch processing, peut être long)
|
||||||
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
|
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
|
||||||
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async
|
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async
|
||||||
|
# Provisioning établissement → async (création BDD, migrations, premier admin)
|
||||||
|
App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand: async
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
# Configuration des tenants en production
|
# Tenants en production : résolution dynamique depuis la base establishments
|
||||||
#
|
#
|
||||||
# En production, les tenants peuvent être configurés de deux façons :
|
# Le DoctrineTenantRegistry interroge la table establishments sur la base master.
|
||||||
# 1. Via la variable d'environnement TENANT_CONFIGS (JSON)
|
# Les nouveaux établissements sont immédiatement accessibles via leur sous-domaine
|
||||||
# 2. Via une implémentation DatabaseTenantRegistry (à implémenter)
|
# sans redémarrage de l'application.
|
||||||
#
|
|
||||||
# Pour l'instant, on utilise InMemoryTenantRegistry avec configuration env.
|
|
||||||
# Si aucun tenant n'est configuré, toutes les requêtes retourneront 404.
|
|
||||||
|
|
||||||
parameters:
|
|
||||||
# Format JSON attendu: [{"tenantId":"uuid","subdomain":"ecole","databaseUrl":"postgres://..."}]
|
|
||||||
tenant.prod_configs_json: '%env(default::TENANT_CONFIGS)%'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
App\Shared\Infrastructure\Tenant\TenantRegistry:
|
App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry:
|
||||||
class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry
|
|
||||||
factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromEnv']
|
|
||||||
arguments:
|
arguments:
|
||||||
$configsJson: '%tenant.prod_configs_json%'
|
$connection: '@doctrine.dbal.master_connection'
|
||||||
|
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||||
|
|
||||||
|
App\Shared\Infrastructure\Tenant\TenantRegistry:
|
||||||
|
alias: App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry
|
||||||
|
|||||||
@@ -247,12 +247,20 @@ services:
|
|||||||
$homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer'
|
$homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer'
|
||||||
|
|
||||||
App\Scolarite\Application\Port\FileStorage:
|
App\Scolarite\Application\Port\FileStorage:
|
||||||
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage
|
alias: App\Scolarite\Infrastructure\Storage\S3FileStorage
|
||||||
|
|
||||||
App\Scolarite\Infrastructure\Storage\LocalFileStorage:
|
App\Scolarite\Infrastructure\Storage\LocalFileStorage:
|
||||||
arguments:
|
arguments:
|
||||||
$storagePath: '%kernel.project_dir%/var/storage'
|
$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)
|
# Schedule (Story 4.1 - Emploi du temps)
|
||||||
App\Scolarite\Domain\Repository\ScheduleSlotRepository:
|
App\Scolarite\Domain\Repository\ScheduleSlotRepository:
|
||||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository
|
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineScheduleSlotRepository
|
||||||
@@ -333,6 +341,23 @@ services:
|
|||||||
App\SuperAdmin\Domain\Repository\EstablishmentRepository:
|
App\SuperAdmin\Domain\Repository\EstablishmentRepository:
|
||||||
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository
|
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository
|
||||||
|
|
||||||
|
# Provisioning (Story 2.17 - Provisioning automatique)
|
||||||
|
App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator:
|
||||||
|
arguments:
|
||||||
|
$connection: '@doctrine.dbal.master_connection'
|
||||||
|
|
||||||
|
App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||||
|
|
||||||
|
App\SuperAdmin\Application\Port\TenantProvisioner:
|
||||||
|
alias: App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner
|
||||||
|
|
||||||
|
App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler:
|
||||||
|
arguments:
|
||||||
|
$masterDatabaseUrl: '%env(DATABASE_URL)%'
|
||||||
|
|
||||||
# School Calendar Repository (Story 2.11 - Calendrier scolaire)
|
# School Calendar Repository (Story 2.11 - Calendrier scolaire)
|
||||||
App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository:
|
App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository:
|
||||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository
|
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository
|
||||||
|
|||||||
@@ -12,4 +12,9 @@ interface FileStorage
|
|||||||
public function upload(string $path, mixed $content, string $mimeType): string;
|
public function upload(string $path, mixed $content, string $mimeType): string;
|
||||||
|
|
||||||
public function delete(string $path): void;
|
public function delete(string $path): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return resource
|
||||||
|
*/
|
||||||
|
public function readStream(string $path): mixed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,26 @@ namespace App\Scolarite\Application\Query\GetBlockedDates;
|
|||||||
|
|
||||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
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 App\Shared\Domain\Tenant\TenantId;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends)
|
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends,
|
||||||
* pour une plage de dates donnée.
|
* 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')]
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
final readonly class GetBlockedDatesHandler
|
final readonly class GetBlockedDatesHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private SchoolCalendarRepository $calendarRepository,
|
private SchoolCalendarRepository $calendarRepository,
|
||||||
|
private HomeworkRulesChecker $rulesChecker,
|
||||||
|
private Clock $clock,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +41,7 @@ final readonly class GetBlockedDatesHandler
|
|||||||
$endDate = new DateTimeImmutable($query->endDate);
|
$endDate = new DateTimeImmutable($query->endDate);
|
||||||
$oneDay = new DateInterval('P1D');
|
$oneDay = new DateInterval('P1D');
|
||||||
|
|
||||||
|
$now = $this->clock->now();
|
||||||
$blockedDates = [];
|
$blockedDates = [];
|
||||||
$current = $startDate;
|
$current = $startDate;
|
||||||
|
|
||||||
@@ -50,15 +55,22 @@ final readonly class GetBlockedDatesHandler
|
|||||||
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
|
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
|
||||||
type: 'weekend',
|
type: 'weekend',
|
||||||
);
|
);
|
||||||
} elseif ($calendar !== null) {
|
} elseif ($calendar !== null && ($entry = $calendar->trouverEntreePourDate($current)) !== null) {
|
||||||
$entry = $calendar->trouverEntreePourDate($current);
|
|
||||||
|
|
||||||
if ($entry !== null) {
|
|
||||||
$blockedDates[] = new BlockedDateDto(
|
$blockedDates[] = new BlockedDateDto(
|
||||||
date: $dateStr,
|
date: $dateStr,
|
||||||
reason: $entry->label,
|
reason: $entry->label,
|
||||||
type: $entry->type->value,
|
type: $entry->type->value,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
$dueDate = new DateTimeImmutable($dateStr);
|
||||||
|
$result = $this->rulesChecker->verifier($tenantId, $dueDate, $now);
|
||||||
|
|
||||||
|
if (!$result->estValide()) {
|
||||||
|
$blockedDates[] = new BlockedDateDto(
|
||||||
|
date: $dateStr,
|
||||||
|
reason: $result->messages()[0] ?? 'Règle de devoirs',
|
||||||
|
type: $result->estBloquant() ? 'rule_hard' : 'rule_soft',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ use App\Scolarite\Domain\Repository\HomeworkRepository;
|
|||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
use function array_map;
|
use function array_map;
|
||||||
use function realpath;
|
use function fclose;
|
||||||
use function str_starts_with;
|
use function fpassthru;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
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\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
@@ -39,8 +39,6 @@ final readonly class HomeworkAttachmentController
|
|||||||
private HomeworkAttachmentRepository $attachmentRepository,
|
private HomeworkAttachmentRepository $attachmentRepository,
|
||||||
private UploadHomeworkAttachmentHandler $uploadHandler,
|
private UploadHomeworkAttachmentHandler $uploadHandler,
|
||||||
private FileStorage $fileStorage,
|
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'])]
|
#[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();
|
$user = $this->getSecurityUser();
|
||||||
$tenantId = TenantId::fromString($user->tenantId());
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
@@ -143,20 +141,29 @@ final readonly class HomeworkAttachmentController
|
|||||||
|
|
||||||
foreach ($attachments as $attachment) {
|
foreach ($attachments as $attachment) {
|
||||||
if ((string) $attachment->id === $attachmentId) {
|
if ((string) $attachment->id === $attachmentId) {
|
||||||
$fullPath = $this->storageDir . '/' . $attachment->filePath;
|
try {
|
||||||
$realPath = realpath($fullPath);
|
$stream = $this->fileStorage->readStream($attachment->filePath);
|
||||||
$realStorageDir = realpath($this->storageDir);
|
} catch (RuntimeException) {
|
||||||
|
|
||||||
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
|
|
||||||
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = new BinaryFileResponse($realPath);
|
$response = new StreamedResponse(static function () use ($stream): void {
|
||||||
$response->setContentDisposition(
|
try {
|
||||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
fpassthru($stream);
|
||||||
|
} finally {
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$disposition = HeaderUtils::makeDisposition(
|
||||||
|
HeaderUtils::DISPOSITION_INLINE,
|
||||||
$attachment->filename,
|
$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;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||||
|
|
||||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
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\ChildHomeworkDto;
|
||||||
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler;
|
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler;
|
||||||
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler;
|
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler;
|
||||||
@@ -18,16 +19,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkParentVoter;
|
|||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
use function array_map;
|
use function array_map;
|
||||||
|
use function fclose;
|
||||||
|
use function fpassthru;
|
||||||
use function is_string;
|
use function is_string;
|
||||||
use function realpath;
|
|
||||||
use function str_starts_with;
|
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
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\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -45,8 +46,7 @@ final readonly class ParentHomeworkController
|
|||||||
private GetChildrenHomeworkDetailHandler $detailHandler,
|
private GetChildrenHomeworkDetailHandler $detailHandler,
|
||||||
private HomeworkRepository $homeworkRepository,
|
private HomeworkRepository $homeworkRepository,
|
||||||
private HomeworkAttachmentRepository $attachmentRepository,
|
private HomeworkAttachmentRepository $attachmentRepository,
|
||||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
private FileStorage $fileStorage,
|
||||||
private string $uploadsDir,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ final readonly class ParentHomeworkController
|
|||||||
* Téléchargement d'une pièce jointe (parent).
|
* 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'])]
|
#[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();
|
$user = $this->getSecurityUser();
|
||||||
$tenantId = TenantId::fromString($user->tenantId());
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
@@ -138,20 +138,29 @@ final readonly class ParentHomeworkController
|
|||||||
|
|
||||||
foreach ($attachments as $attachment) {
|
foreach ($attachments as $attachment) {
|
||||||
if ((string) $attachment->id === $attachmentId) {
|
if ((string) $attachment->id === $attachmentId) {
|
||||||
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
|
try {
|
||||||
$realPath = realpath($fullPath);
|
$stream = $this->fileStorage->readStream($attachment->filePath);
|
||||||
$realUploadsDir = realpath($this->uploadsDir);
|
} catch (RuntimeException) {
|
||||||
|
|
||||||
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
|
||||||
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = new BinaryFileResponse($realPath);
|
$response = new StreamedResponse(static function () use ($stream): void {
|
||||||
$response->setContentDisposition(
|
try {
|
||||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
fpassthru($stream);
|
||||||
|
} finally {
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$disposition = HeaderUtils::makeDisposition(
|
||||||
|
HeaderUtils::DISPOSITION_INLINE,
|
||||||
$attachment->filename,
|
$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;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||||
|
|
||||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Scolarite\Application\Port\FileStorage;
|
||||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||||
use App\Scolarite\Application\Port\StudentClassReader;
|
use App\Scolarite\Application\Port\StudentClassReader;
|
||||||
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler;
|
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler;
|
||||||
@@ -19,16 +20,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkStudentVoter;
|
|||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
use function array_map;
|
use function array_map;
|
||||||
|
use function fclose;
|
||||||
|
use function fpassthru;
|
||||||
use function is_string;
|
use function is_string;
|
||||||
use function realpath;
|
|
||||||
use function str_starts_with;
|
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
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\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -44,8 +45,7 @@ final readonly class StudentHomeworkController
|
|||||||
private HomeworkAttachmentRepository $attachmentRepository,
|
private HomeworkAttachmentRepository $attachmentRepository,
|
||||||
private ScheduleDisplayReader $displayReader,
|
private ScheduleDisplayReader $displayReader,
|
||||||
private StudentClassReader $studentClassReader,
|
private StudentClassReader $studentClassReader,
|
||||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
private FileStorage $fileStorage,
|
||||||
private string $uploadsDir,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ final readonly class StudentHomeworkController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/api/me/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_student_homework_attachment', methods: ['GET'])]
|
#[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();
|
$user = $this->getSecurityUser();
|
||||||
$tenantId = TenantId::fromString($user->tenantId());
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
@@ -115,20 +115,29 @@ final readonly class StudentHomeworkController
|
|||||||
|
|
||||||
foreach ($attachments as $attachment) {
|
foreach ($attachments as $attachment) {
|
||||||
if ((string) $attachment->id === $attachmentId) {
|
if ((string) $attachment->id === $attachmentId) {
|
||||||
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
|
try {
|
||||||
$realPath = realpath($fullPath);
|
$stream = $this->fileStorage->readStream($attachment->filePath);
|
||||||
$realUploadsDir = realpath($this->uploadsDir);
|
} catch (RuntimeException) {
|
||||||
|
|
||||||
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
|
||||||
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = new BinaryFileResponse($realPath);
|
$response = new StreamedResponse(static function () use ($stream): void {
|
||||||
$response->setContentDisposition(
|
try {
|
||||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
fpassthru($stream);
|
||||||
|
} finally {
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$disposition = HeaderUtils::makeDisposition(
|
||||||
|
HeaderUtils::DISPOSITION_INLINE,
|
||||||
$attachment->filename,
|
$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;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Scolarite\Infrastructure\Api\Controller;
|
|||||||
|
|
||||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
use App\Scolarite\Application\Port\ClassStudentsReader;
|
use App\Scolarite\Application\Port\ClassStudentsReader;
|
||||||
|
use App\Scolarite\Application\Port\FileStorage;
|
||||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
||||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||||
@@ -24,15 +25,15 @@ use function count;
|
|||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
use function fclose;
|
||||||
|
use function fpassthru;
|
||||||
use function in_array;
|
use function in_array;
|
||||||
use function realpath;
|
|
||||||
use function str_starts_with;
|
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
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\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -45,8 +46,7 @@ final readonly class TeacherSubmissionController
|
|||||||
private HomeworkSubmissionRepository $submissionRepository,
|
private HomeworkSubmissionRepository $submissionRepository,
|
||||||
private SubmissionAttachmentRepository $attachmentRepository,
|
private SubmissionAttachmentRepository $attachmentRepository,
|
||||||
private ClassStudentsReader $classStudentsReader,
|
private ClassStudentsReader $classStudentsReader,
|
||||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
private FileStorage $fileStorage,
|
||||||
private string $storageDir,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ final readonly class TeacherSubmissionController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/api/homework/{homeworkId}/submissions/{submissionId}/attachments/{attachmentId}', name: 'api_teacher_submission_attachment_download', methods: ['GET'])]
|
#[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();
|
$user = $this->getSecurityUser();
|
||||||
$tenantId = TenantId::fromString($user->tenantId());
|
$tenantId = TenantId::fromString($user->tenantId());
|
||||||
@@ -268,20 +268,29 @@ final readonly class TeacherSubmissionController
|
|||||||
|
|
||||||
foreach ($attachments as $attachment) {
|
foreach ($attachments as $attachment) {
|
||||||
if ((string) $attachment->id === $attachmentId) {
|
if ((string) $attachment->id === $attachmentId) {
|
||||||
$fullPath = $this->storageDir . '/' . $attachment->filePath;
|
try {
|
||||||
$realPath = realpath($fullPath);
|
$stream = $this->fileStorage->readStream($attachment->filePath);
|
||||||
$realStorageDir = realpath($this->storageDir);
|
} catch (RuntimeException) {
|
||||||
|
|
||||||
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
|
|
||||||
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
throw new NotFoundHttpException('Pièce jointe non trouvée.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = new BinaryFileResponse($realPath);
|
$response = new StreamedResponse(static function () use ($stream): void {
|
||||||
$response->setContentDisposition(
|
try {
|
||||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
fpassthru($stream);
|
||||||
|
} finally {
|
||||||
|
fclose($stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$disposition = HeaderUtils::makeDisposition(
|
||||||
|
HeaderUtils::DISPOSITION_INLINE,
|
||||||
$attachment->filename,
|
$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;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Scolarite\Application\Port\FileStorage;
|
|||||||
|
|
||||||
use function dirname;
|
use function dirname;
|
||||||
use function file_put_contents;
|
use function file_put_contents;
|
||||||
|
use function fopen;
|
||||||
use function is_dir;
|
use function is_dir;
|
||||||
use function is_file;
|
use function is_file;
|
||||||
use function is_string;
|
use function is_string;
|
||||||
@@ -15,6 +16,12 @@ use function mkdir;
|
|||||||
|
|
||||||
use Override;
|
use Override;
|
||||||
|
|
||||||
|
use function realpath;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
use function str_starts_with;
|
||||||
use function unlink;
|
use function unlink;
|
||||||
|
|
||||||
final readonly class LocalFileStorage implements FileStorage
|
final readonly class LocalFileStorage implements FileStorage
|
||||||
@@ -50,4 +57,24 @@ final readonly class LocalFileStorage implements FileStorage
|
|||||||
unlink($fullPath);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Tenant;
|
||||||
|
|
||||||
|
use function array_values;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
use function parse_url;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Contracts\Service\ResetInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves tenants dynamically from the establishments table in the master database.
|
||||||
|
*
|
||||||
|
* Unlike InMemoryTenantRegistry (loaded from static config), this implementation
|
||||||
|
* makes newly created establishments immediately accessible via their subdomain
|
||||||
|
* without restarting the application.
|
||||||
|
*
|
||||||
|
* Results are lazy-loaded and cached in memory for the duration of the request.
|
||||||
|
* Implements ResetInterface so long-running workers invalidate the cache between messages.
|
||||||
|
*/
|
||||||
|
final class DoctrineTenantRegistry implements TenantRegistry, ResetInterface
|
||||||
|
{
|
||||||
|
/** @var array<string, TenantConfig>|null Indexed by tenant ID */
|
||||||
|
private ?array $byId = null;
|
||||||
|
|
||||||
|
/** @var array<string, TenantConfig>|null Indexed by subdomain */
|
||||||
|
private ?array $bySubdomain = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
private readonly string $masterDatabaseUrl,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function getConfig(TenantId $tenantId): TenantConfig
|
||||||
|
{
|
||||||
|
$this->ensureLoaded();
|
||||||
|
|
||||||
|
$key = (string) $tenantId;
|
||||||
|
|
||||||
|
if (!isset($this->byId[$key])) {
|
||||||
|
throw TenantNotFoundException::withId($tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->byId[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function getBySubdomain(string $subdomain): TenantConfig
|
||||||
|
{
|
||||||
|
$this->ensureLoaded();
|
||||||
|
|
||||||
|
if (!isset($this->bySubdomain[$subdomain])) {
|
||||||
|
throw TenantNotFoundException::withSubdomain($subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->bySubdomain[$subdomain];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function exists(string $subdomain): bool
|
||||||
|
{
|
||||||
|
$this->ensureLoaded();
|
||||||
|
|
||||||
|
return isset($this->bySubdomain[$subdomain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function getAllConfigs(): array
|
||||||
|
{
|
||||||
|
$this->ensureLoaded();
|
||||||
|
|
||||||
|
/** @var array<string, TenantConfig> $byId */
|
||||||
|
$byId = $this->byId;
|
||||||
|
|
||||||
|
return array_values($byId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function reset(): void
|
||||||
|
{
|
||||||
|
$this->byId = null;
|
||||||
|
$this->bySubdomain = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureLoaded(): void
|
||||||
|
{
|
||||||
|
if ($this->byId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->byId = [];
|
||||||
|
$this->bySubdomain = [];
|
||||||
|
|
||||||
|
/** @var array<array{tenant_id: string, subdomain: string, database_name: string}> $rows */
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
"SELECT tenant_id, subdomain, database_name FROM establishments WHERE status = 'active'",
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$config = new TenantConfig(
|
||||||
|
tenantId: TenantId::fromString($row['tenant_id']),
|
||||||
|
subdomain: $row['subdomain'],
|
||||||
|
databaseUrl: $this->buildDatabaseUrl($row['database_name']),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->byId[$row['tenant_id']] = $config;
|
||||||
|
$this->bySubdomain[$row['subdomain']] = $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDatabaseUrl(string $databaseName): string
|
||||||
|
{
|
||||||
|
$parsed = parse_url($this->masterDatabaseUrl);
|
||||||
|
|
||||||
|
$scheme = $parsed['scheme'] ?? 'postgresql';
|
||||||
|
$user = $parsed['user'] ?? '';
|
||||||
|
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
|
||||||
|
$host = $parsed['host'] ?? 'localhost';
|
||||||
|
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||||
|
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||||
|
|
||||||
|
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,23 +17,18 @@ final readonly class CreateEstablishmentHandler
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __invoke(CreateEstablishmentCommand $command): CreateEstablishmentResult
|
public function __invoke(CreateEstablishmentCommand $command): Establishment
|
||||||
{
|
{
|
||||||
$establishment = Establishment::creer(
|
$establishment = Establishment::creer(
|
||||||
name: $command->name,
|
name: $command->name,
|
||||||
subdomain: $command->subdomain,
|
subdomain: $command->subdomain,
|
||||||
|
adminEmail: $command->adminEmail,
|
||||||
createdBy: SuperAdminId::fromString($command->superAdminId),
|
createdBy: SuperAdminId::fromString($command->superAdminId),
|
||||||
createdAt: $this->clock->now(),
|
createdAt: $this->clock->now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->establishmentRepository->save($establishment);
|
$this->establishmentRepository->save($establishment);
|
||||||
|
|
||||||
return new CreateEstablishmentResult(
|
return $establishment;
|
||||||
establishmentId: (string) $establishment->id,
|
|
||||||
tenantId: (string) $establishment->tenantId,
|
|
||||||
name: $establishment->name,
|
|
||||||
subdomain: $establishment->subdomain,
|
|
||||||
databaseName: $establishment->databaseName,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\SuperAdmin\Application\Command\CreateEstablishment;
|
|
||||||
|
|
||||||
final readonly class CreateEstablishmentResult
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $establishmentId,
|
|
||||||
public string $tenantId,
|
|
||||||
public string $name,
|
|
||||||
public string $subdomain,
|
|
||||||
public string $databaseName,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Command\ProvisionEstablishment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers async provisioning of a newly created establishment.
|
||||||
|
*
|
||||||
|
* Property names intentionally avoid "tenantId" to prevent the
|
||||||
|
* TenantDatabaseMiddleware from trying to switch to a database
|
||||||
|
* that doesn't exist yet.
|
||||||
|
*/
|
||||||
|
final readonly class ProvisionEstablishmentCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $establishmentId,
|
||||||
|
public string $establishmentTenantId,
|
||||||
|
public string $databaseName,
|
||||||
|
public string $subdomain,
|
||||||
|
public string $adminEmail,
|
||||||
|
public string $establishmentName,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Port;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a tenant database and runs migrations.
|
||||||
|
*/
|
||||||
|
interface TenantProvisioner
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Creates the tenant database and applies the schema.
|
||||||
|
*
|
||||||
|
* @throws RuntimeException if provisioning fails
|
||||||
|
*/
|
||||||
|
public function provision(string $databaseName): void;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ final readonly class EtablissementCree implements DomainEvent
|
|||||||
public TenantId $tenantId,
|
public TenantId $tenantId,
|
||||||
public string $name,
|
public string $name,
|
||||||
public string $subdomain,
|
public string $subdomain,
|
||||||
|
public string $adminEmail,
|
||||||
private DateTimeImmutable $occurredOn,
|
private DateTimeImmutable $occurredOn,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ final class Establishment extends AggregateRoot
|
|||||||
public static function creer(
|
public static function creer(
|
||||||
string $name,
|
string $name,
|
||||||
string $subdomain,
|
string $subdomain,
|
||||||
|
string $adminEmail,
|
||||||
SuperAdminId $createdBy,
|
SuperAdminId $createdBy,
|
||||||
DateTimeImmutable $createdAt,
|
DateTimeImmutable $createdAt,
|
||||||
): self {
|
): self {
|
||||||
@@ -49,7 +50,7 @@ final class Establishment extends AggregateRoot
|
|||||||
name: $name,
|
name: $name,
|
||||||
subdomain: $subdomain,
|
subdomain: $subdomain,
|
||||||
databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)),
|
databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)),
|
||||||
status: EstablishmentStatus::ACTIF,
|
status: EstablishmentStatus::PROVISIONING,
|
||||||
createdAt: $createdAt,
|
createdAt: $createdAt,
|
||||||
createdBy: $createdBy,
|
createdBy: $createdBy,
|
||||||
);
|
);
|
||||||
@@ -59,12 +60,18 @@ final class Establishment extends AggregateRoot
|
|||||||
tenantId: $establishment->tenantId,
|
tenantId: $establishment->tenantId,
|
||||||
name: $name,
|
name: $name,
|
||||||
subdomain: $subdomain,
|
subdomain: $subdomain,
|
||||||
|
adminEmail: $adminEmail,
|
||||||
occurredOn: $createdAt,
|
occurredOn: $createdAt,
|
||||||
));
|
));
|
||||||
|
|
||||||
return $establishment;
|
return $establishment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function activer(): void
|
||||||
|
{
|
||||||
|
$this->status = EstablishmentStatus::ACTIF;
|
||||||
|
}
|
||||||
|
|
||||||
public function desactiver(DateTimeImmutable $at): void
|
public function desactiver(DateTimeImmutable $at): void
|
||||||
{
|
{
|
||||||
if ($this->status !== EstablishmentStatus::ACTIF) {
|
if ($this->status !== EstablishmentStatus::ACTIF) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\SuperAdmin\Domain\Model\Establishment;
|
|||||||
|
|
||||||
enum EstablishmentStatus: string
|
enum EstablishmentStatus: string
|
||||||
{
|
{
|
||||||
|
case PROVISIONING = 'provisioning';
|
||||||
case ACTIF = 'active';
|
case ACTIF = 'active';
|
||||||
case INACTIF = 'inactive';
|
case INACTIF = 'inactive';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
|
||||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
use Override;
|
use Override;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements ProcessorInterface<EstablishmentResource, EstablishmentResource>
|
* @implements ProcessorInterface<EstablishmentResource, EstablishmentResource>
|
||||||
@@ -21,6 +23,7 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private CreateEstablishmentHandler $handler,
|
private CreateEstablishmentHandler $handler,
|
||||||
private Security $security,
|
private Security $security,
|
||||||
|
private MessageBusInterface $commandBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,20 +36,29 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
|
|||||||
/** @var SecuritySuperAdmin $user */
|
/** @var SecuritySuperAdmin $user */
|
||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
$result = ($this->handler)(new CreateEstablishmentCommand(
|
$establishment = ($this->handler)(new CreateEstablishmentCommand(
|
||||||
name: $data->name,
|
name: $data->name,
|
||||||
subdomain: $data->subdomain,
|
subdomain: $data->subdomain,
|
||||||
adminEmail: $data->adminEmail,
|
adminEmail: $data->adminEmail,
|
||||||
superAdminId: $user->superAdminId(),
|
superAdminId: $user->superAdminId(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
$this->commandBus->dispatch(new ProvisionEstablishmentCommand(
|
||||||
|
establishmentId: (string) $establishment->id,
|
||||||
|
establishmentTenantId: (string) $establishment->tenantId,
|
||||||
|
databaseName: $establishment->databaseName,
|
||||||
|
subdomain: $establishment->subdomain,
|
||||||
|
adminEmail: $data->adminEmail,
|
||||||
|
establishmentName: $establishment->name,
|
||||||
|
));
|
||||||
|
|
||||||
$resource = new EstablishmentResource();
|
$resource = new EstablishmentResource();
|
||||||
$resource->id = $result->establishmentId;
|
$resource->id = (string) $establishment->id;
|
||||||
$resource->tenantId = $result->tenantId;
|
$resource->tenantId = (string) $establishment->tenantId;
|
||||||
$resource->name = $result->name;
|
$resource->name = $establishment->name;
|
||||||
$resource->subdomain = $result->subdomain;
|
$resource->subdomain = $establishment->subdomain;
|
||||||
$resource->databaseName = $result->databaseName;
|
$resource->databaseName = $establishment->databaseName;
|
||||||
$resource->status = 'active';
|
$resource->status = $establishment->status->value;
|
||||||
|
|
||||||
return $resource;
|
return $resource;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provisions a tenant by creating the database and running migrations.
|
||||||
|
*/
|
||||||
|
final readonly class DatabaseTenantProvisioner implements TenantProvisioner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TenantDatabaseCreator $databaseCreator,
|
||||||
|
private TenantMigrator $migrator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function provision(string $databaseName): void
|
||||||
|
{
|
||||||
|
$this->databaseCreator->create($databaseName);
|
||||||
|
$this->migrator->migrate($databaseName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\InviteUser\InviteUserCommand;
|
||||||
|
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||||
|
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
|
||||||
|
|
||||||
|
use function parse_url;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the complete provisioning of a new establishment:
|
||||||
|
* 1. Creates the tenant database and runs migrations
|
||||||
|
* 2. Creates the first admin user (idempotent)
|
||||||
|
* 3. Activates the establishment
|
||||||
|
* 4. Dispatches invitation events (after activation so the tenant is resolvable)
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class ProvisionEstablishmentHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TenantProvisioner $tenantProvisioner,
|
||||||
|
private InviteUserHandler $inviteUserHandler,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
private TenantDatabaseSwitcher $databaseSwitcher,
|
||||||
|
private EstablishmentRepository $establishmentRepository,
|
||||||
|
private MessageBusInterface $eventBus,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private string $masterDatabaseUrl,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(ProvisionEstablishmentCommand $command): void
|
||||||
|
{
|
||||||
|
$this->logger->info('Starting establishment provisioning.', [
|
||||||
|
'establishment' => $command->establishmentId,
|
||||||
|
'subdomain' => $command->subdomain,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->tenantProvisioner->provision($command->databaseName);
|
||||||
|
|
||||||
|
// Create admin user on the tenant database, collect events without dispatching
|
||||||
|
$pendingEvents = $this->createFirstAdminOnTenantDb($command);
|
||||||
|
|
||||||
|
// Activate establishment on master DB so the tenant becomes resolvable
|
||||||
|
$this->activateEstablishment($command->establishmentId);
|
||||||
|
|
||||||
|
// Now dispatch events — the tenant is active and resolvable by the middleware
|
||||||
|
foreach ($pendingEvents as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Establishment provisioning completed.', [
|
||||||
|
'establishment' => $command->establishmentId,
|
||||||
|
'subdomain' => $command->subdomain,
|
||||||
|
'adminEmail' => $command->adminEmail,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DomainEvent[]
|
||||||
|
*/
|
||||||
|
private function createFirstAdminOnTenantDb(ProvisionEstablishmentCommand $command): array
|
||||||
|
{
|
||||||
|
$tenantDatabaseUrl = $this->buildTenantDatabaseUrl($command->databaseName);
|
||||||
|
$this->databaseSwitcher->useTenantDatabase($tenantDatabaseUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->createFirstAdmin($command);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->restoreDefaultDatabase();
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
$this->restoreDefaultDatabase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DomainEvent[]
|
||||||
|
*/
|
||||||
|
private function createFirstAdmin(ProvisionEstablishmentCommand $command): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$user = ($this->inviteUserHandler)(new InviteUserCommand(
|
||||||
|
tenantId: $command->establishmentTenantId,
|
||||||
|
schoolName: $command->establishmentName,
|
||||||
|
email: $command->adminEmail,
|
||||||
|
role: Role::ADMIN->value,
|
||||||
|
firstName: 'Administrateur',
|
||||||
|
lastName: $command->establishmentName,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $user->pullDomainEvents();
|
||||||
|
} catch (EmailDejaUtiliseeException) {
|
||||||
|
$this->logger->info('Admin already exists, re-sending invitation.', [
|
||||||
|
'email' => $command->adminEmail,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->resendInvitation($command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DomainEvent[]
|
||||||
|
*/
|
||||||
|
private function resendInvitation(ProvisionEstablishmentCommand $command): array
|
||||||
|
{
|
||||||
|
$existingUser = $this->userRepository->findByEmail(
|
||||||
|
new Email($command->adminEmail),
|
||||||
|
TenantId::fromString($command->establishmentTenantId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existingUser === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingUser->renvoyerInvitation($this->clock->now());
|
||||||
|
$this->userRepository->save($existingUser);
|
||||||
|
|
||||||
|
return $existingUser->pullDomainEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activateEstablishment(string $establishmentId): void
|
||||||
|
{
|
||||||
|
$establishment = $this->establishmentRepository->get(
|
||||||
|
EstablishmentId::fromString($establishmentId),
|
||||||
|
);
|
||||||
|
$establishment->activer();
|
||||||
|
$this->establishmentRepository->save($establishment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function restoreDefaultDatabase(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->databaseSwitcher->useDefaultDatabase();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('Failed to restore default database connection.', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTenantDatabaseUrl(string $databaseName): string
|
||||||
|
{
|
||||||
|
$parsed = parse_url($this->masterDatabaseUrl);
|
||||||
|
|
||||||
|
$scheme = $parsed['scheme'] ?? 'postgresql';
|
||||||
|
$user = $parsed['user'] ?? '';
|
||||||
|
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
|
||||||
|
$host = $parsed['host'] ?? 'localhost';
|
||||||
|
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||||
|
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||||
|
|
||||||
|
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use function preg_match;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
use function str_replace;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a PostgreSQL database for a new tenant.
|
||||||
|
*
|
||||||
|
* Extracted from the tenant:database:create console command
|
||||||
|
* to be usable programmatically during provisioning.
|
||||||
|
*/
|
||||||
|
final readonly class TenantDatabaseCreator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private string $databaseUser = 'classeo',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws RuntimeException if database name is invalid or creation fails
|
||||||
|
*/
|
||||||
|
public function create(string $databaseName): void
|
||||||
|
{
|
||||||
|
if (!preg_match('/^classeo_tenant_[a-z0-9_]+$/', $databaseName)) {
|
||||||
|
throw new RuntimeException(sprintf('Invalid tenant database name: "%s"', $databaseName));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$exists = $this->connection->fetchOne(
|
||||||
|
'SELECT 1 FROM pg_database WHERE datname = :name',
|
||||||
|
['name' => $databaseName],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($exists !== false) {
|
||||||
|
$this->logger->info('Tenant database already exists, skipping creation.', [
|
||||||
|
'database' => $databaseName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->connection->executeStatement(sprintf(
|
||||||
|
"CREATE DATABASE %s WITH OWNER = %s ENCODING = 'UTF8' LC_COLLATE = 'en_US.utf8' LC_CTYPE = 'en_US.utf8'",
|
||||||
|
$this->quoteIdentifier($databaseName),
|
||||||
|
$this->quoteIdentifier($this->databaseUser),
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->logger->info('Tenant database created.', ['database' => $databaseName]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf('Failed to create tenant database "%s": %s', $databaseName, $e->getMessage()),
|
||||||
|
previous: $e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quoteIdentifier(string $identifier): string
|
||||||
|
{
|
||||||
|
return '"' . str_replace('"', '""', $identifier) . '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use function getenv;
|
||||||
|
use function parse_url;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs Doctrine migrations for a tenant database.
|
||||||
|
*
|
||||||
|
* Spawns a subprocess with DATABASE_URL pointing to the tenant database,
|
||||||
|
* so Doctrine connects to the correct database before the kernel boots.
|
||||||
|
*/
|
||||||
|
final readonly class TenantMigrator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private string $projectDir,
|
||||||
|
private string $masterDatabaseUrl,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws RuntimeException if migration fails
|
||||||
|
*/
|
||||||
|
public function migrate(string $databaseName): void
|
||||||
|
{
|
||||||
|
$databaseUrl = $this->buildDatabaseUrl($databaseName);
|
||||||
|
|
||||||
|
$process = new Process(
|
||||||
|
command: ['php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction'],
|
||||||
|
cwd: $this->projectDir,
|
||||||
|
env: [
|
||||||
|
...getenv(),
|
||||||
|
'DATABASE_URL' => $databaseUrl,
|
||||||
|
],
|
||||||
|
timeout: 300,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->info('Running migrations for tenant database.', ['database' => $databaseName]);
|
||||||
|
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (!$process->isSuccessful()) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Migration failed for tenant database "%s": %s',
|
||||||
|
$databaseName,
|
||||||
|
$process->getErrorOutput(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Migrations completed for tenant database.', ['database' => $databaseName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildDatabaseUrl(string $databaseName): string
|
||||||
|
{
|
||||||
|
$parsed = parse_url($this->masterDatabaseUrl);
|
||||||
|
|
||||||
|
$scheme = $parsed['scheme'] ?? 'postgresql';
|
||||||
|
$user = $parsed['user'] ?? '';
|
||||||
|
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
|
||||||
|
$host = $parsed['host'] ?? 'localhost';
|
||||||
|
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
|
||||||
|
|
||||||
|
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
|
||||||
|
|
||||||
|
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Shared\Infrastructure\Audit;
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||||
|
use App\Shared\Application\Port\AuditLogger;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [P1] Functional tests for audit trail infrastructure.
|
||||||
|
*
|
||||||
|
* Verifies that the AuditLogger writes to the real audit_log table
|
||||||
|
* and that entries contain correct metadata.
|
||||||
|
*
|
||||||
|
* @see NFR-S7: Audit trail immutable (qui, quoi, quand)
|
||||||
|
* @see FR90: Tracage actions sensibles
|
||||||
|
*/
|
||||||
|
final class AuditTrailFunctionalTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
protected static ?bool $alwaysBootKernel = true;
|
||||||
|
|
||||||
|
private Connection $connection;
|
||||||
|
private AuditLogger $auditLogger;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
static::bootKernel();
|
||||||
|
$container = static::getContainer();
|
||||||
|
|
||||||
|
/* @var Connection $connection */
|
||||||
|
$this->connection = $container->get(Connection::class);
|
||||||
|
|
||||||
|
/* @var AuditLogger $auditLogger */
|
||||||
|
$this->auditLogger = $container->get(AuditLogger::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function logAuthenticationWritesEntryToAuditLogTable(): void
|
||||||
|
{
|
||||||
|
$userId = Uuid::uuid4();
|
||||||
|
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionReussie',
|
||||||
|
userId: $userId,
|
||||||
|
payload: [
|
||||||
|
'email_hash' => hash('sha256', 'test@example.com'),
|
||||||
|
'result' => 'success',
|
||||||
|
'method' => 'password',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$entry = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||||
|
[$userId->toString(), 'ConnexionReussie'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotFalse($entry, 'Audit log entry should exist after logAuthentication');
|
||||||
|
self::assertSame('User', $entry['aggregate_type']);
|
||||||
|
self::assertSame($userId->toString(), $entry['aggregate_id']);
|
||||||
|
self::assertSame('ConnexionReussie', $entry['event_type']);
|
||||||
|
|
||||||
|
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame('success', $payload['result']);
|
||||||
|
self::assertSame('password', $payload['method']);
|
||||||
|
self::assertArrayHasKey('email_hash', $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function logAuthenticationIncludesMetadataWithTimestamp(): void
|
||||||
|
{
|
||||||
|
$userId = Uuid::uuid4();
|
||||||
|
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionReussie',
|
||||||
|
userId: $userId,
|
||||||
|
payload: ['result' => 'success'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$entry = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM audit_log WHERE aggregate_id = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||||
|
[$userId->toString()],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotFalse($entry);
|
||||||
|
self::assertNotEmpty($entry['occurred_at'], 'Audit entry must have a timestamp');
|
||||||
|
|
||||||
|
$metadata = json_decode($entry['metadata'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertIsArray($metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function logFailedAuthenticationWritesWithNullUserId(): void
|
||||||
|
{
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionEchouee',
|
||||||
|
userId: null,
|
||||||
|
payload: [
|
||||||
|
'email_hash' => hash('sha256', 'unknown@example.com'),
|
||||||
|
'result' => 'failure',
|
||||||
|
'reason' => 'invalid_credentials',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$entry = $this->connection->fetchAssociative(
|
||||||
|
"SELECT * FROM audit_log WHERE event_type = 'ConnexionEchouee' ORDER BY occurred_at DESC LIMIT 1",
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotFalse($entry, 'Failed login audit entry should exist');
|
||||||
|
self::assertNull($entry['aggregate_id'], 'Failed login should have null user ID');
|
||||||
|
self::assertSame('User', $entry['aggregate_type']);
|
||||||
|
|
||||||
|
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame('failure', $payload['result']);
|
||||||
|
self::assertSame('invalid_credentials', $payload['reason']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function logDataChangeWritesOldAndNewValues(): void
|
||||||
|
{
|
||||||
|
$aggregateId = Uuid::uuid4();
|
||||||
|
|
||||||
|
$this->auditLogger->logDataChange(
|
||||||
|
aggregateType: 'Grade',
|
||||||
|
aggregateId: $aggregateId,
|
||||||
|
eventType: 'GradeModified',
|
||||||
|
oldValues: ['value' => 14.0],
|
||||||
|
newValues: ['value' => 16.0],
|
||||||
|
reason: 'Correction erreur de saisie',
|
||||||
|
);
|
||||||
|
|
||||||
|
$entry = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1',
|
||||||
|
[$aggregateId->toString(), 'GradeModified'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotFalse($entry);
|
||||||
|
self::assertSame('Grade', $entry['aggregate_type']);
|
||||||
|
|
||||||
|
$payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame(['value' => 14.0], $payload['old_values']);
|
||||||
|
self::assertSame(['value' => 16.0], $payload['new_values']);
|
||||||
|
self::assertSame('Correction erreur de saisie', $payload['reason']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function auditLogEntriesAreAppendOnly(): void
|
||||||
|
{
|
||||||
|
$userId = Uuid::uuid4();
|
||||||
|
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionReussie',
|
||||||
|
userId: $userId,
|
||||||
|
payload: ['result' => 'success'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$countBefore = (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||||
|
[$userId->toString()],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(1, $countBefore);
|
||||||
|
|
||||||
|
// Log a second event for the same user
|
||||||
|
$this->auditLogger->logAuthentication(
|
||||||
|
eventType: 'ConnexionReussie',
|
||||||
|
userId: $userId,
|
||||||
|
payload: ['result' => 'success'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$countAfter = (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?',
|
||||||
|
[$userId->toString()],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both entries should exist (append-only, no overwrite)
|
||||||
|
self::assertSame(2, $countAfter, 'Audit log must be append-only — both entries should exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ use function sprintf;
|
|||||||
use Symfony\Component\HttpClient\HttpClient;
|
use Symfony\Component\HttpClient\HttpClient;
|
||||||
|
|
||||||
use function sys_get_temp_dir;
|
use function sys_get_temp_dir;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
use function unlink;
|
use function unlink;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +45,16 @@ final class GouvFrCalendarApiTest extends TestCase
|
|||||||
|
|
||||||
protected function setUp(): void
|
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();
|
$this->tempDir = sys_get_temp_dir() . '/classeo-calendar-test-' . uniqid();
|
||||||
mkdir($this->tempDir);
|
mkdir($this->tempDir);
|
||||||
|
|
||||||
@@ -55,6 +68,10 @@ final class GouvFrCalendarApiTest extends TestCase
|
|||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
|
if (!isset($this->tempDir) || !is_dir($this->tempDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Supprimer les fichiers générés
|
// Supprimer les fichiers générés
|
||||||
$files = glob($this->tempDir . '/*.json');
|
$files = glob($this->tempDir . '/*.json');
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ final class UploadSubmissionAttachmentHandlerTest extends TestCase
|
|||||||
public function delete(string $path): void
|
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 {
|
$clock = new class implements Clock {
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
|
|||||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
|
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\GetBlockedDatesHandler;
|
||||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
|
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
@@ -28,7 +31,29 @@ final class GetBlockedDatesHandlerTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->calendarRepository = new InMemorySchoolCalendarRepository();
|
$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]
|
#[Test]
|
||||||
@@ -110,6 +135,93 @@ final class GetBlockedDatesHandlerTest extends TestCase
|
|||||||
self::assertCount(5, $vacations);
|
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
|
private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar
|
||||||
{
|
{
|
||||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,16 @@ namespace App\Tests\Unit\Scolarite\Infrastructure\Storage;
|
|||||||
|
|
||||||
use App\Scolarite\Application\Port\FileStorage;
|
use App\Scolarite\Application\Port\FileStorage;
|
||||||
|
|
||||||
|
use function fopen;
|
||||||
|
use function fwrite;
|
||||||
use function is_string;
|
use function is_string;
|
||||||
|
|
||||||
use Override;
|
use Override;
|
||||||
|
|
||||||
|
use function rewind;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
final class InMemoryFileStorage implements FileStorage
|
final class InMemoryFileStorage implements FileStorage
|
||||||
{
|
{
|
||||||
/** @var array<string, string> */
|
/** @var array<string, string> */
|
||||||
@@ -29,6 +35,21 @@ final class InMemoryFileStorage implements FileStorage
|
|||||||
unset($this->files[$path]);
|
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
|
public function has(string $path): bool
|
||||||
{
|
{
|
||||||
return isset($this->files[$path]);
|
return isset($this->files[$path]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
#[CoversClass(DoctrineTenantRegistry::class)]
|
||||||
|
final class DoctrineTenantRegistryTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||||
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
private const string SUBDOMAIN = 'ecole-alpha';
|
||||||
|
private const string DB_NAME = 'classeo_tenant_a1b2c3d4e5f67890abcdef1234567890';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itResolvesConfigBySubdomain(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$config = $registry->getBySubdomain(self::SUBDOMAIN);
|
||||||
|
|
||||||
|
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||||
|
self::assertSame(self::TENANT_ID, (string) $config->tenantId);
|
||||||
|
self::assertSame('postgresql://classeo:secret@db:5432/' . self::DB_NAME, $config->databaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itResolvesConfigByTenantId(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$config = $registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||||
|
|
||||||
|
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsForUnknownSubdomain(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([]);
|
||||||
|
|
||||||
|
$this->expectException(TenantNotFoundException::class);
|
||||||
|
$registry->getBySubdomain('inexistant');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsForUnknownTenantId(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([]);
|
||||||
|
|
||||||
|
$this->expectException(TenantNotFoundException::class);
|
||||||
|
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itChecksExistence(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue($registry->exists(self::SUBDOMAIN));
|
||||||
|
self::assertFalse($registry->exists('inexistant'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsAllConfigs(): void
|
||||||
|
{
|
||||||
|
$registry = $this->registryWith([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
['tenant_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'subdomain' => 'ecole-beta', 'database_name' => 'classeo_tenant_beta'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$configs = $registry->getAllConfigs();
|
||||||
|
|
||||||
|
self::assertCount(2, $configs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itQueriesDatabaseOnlyOnce(): void
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection->expects(self::once())
|
||||||
|
->method('fetchAllAssociative')
|
||||||
|
->willReturn([
|
||||||
|
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$registry = new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||||
|
|
||||||
|
$registry->getBySubdomain(self::SUBDOMAIN);
|
||||||
|
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||||
|
$registry->exists(self::SUBDOMAIN);
|
||||||
|
$registry->getAllConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array{tenant_id: string, subdomain: string, database_name: string}> $rows
|
||||||
|
*/
|
||||||
|
private function registryWith(array $rows): DoctrineTenantRegistry
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection->method('fetchAllAssociative')->willReturn($rows);
|
||||||
|
|
||||||
|
return new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function createsEstablishmentAndReturnsResult(): void
|
public function createsEstablishmentAndReturnsIt(): void
|
||||||
{
|
{
|
||||||
$command = new CreateEstablishmentCommand(
|
$command = new CreateEstablishmentCommand(
|
||||||
name: 'École Alpha',
|
name: 'École Alpha',
|
||||||
@@ -46,13 +46,13 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
|||||||
superAdminId: self::SUPER_ADMIN_ID,
|
superAdminId: self::SUPER_ADMIN_ID,
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = ($this->handler)($command);
|
$establishment = ($this->handler)($command);
|
||||||
|
|
||||||
self::assertNotEmpty($result->establishmentId);
|
self::assertNotEmpty((string) $establishment->id);
|
||||||
self::assertNotEmpty($result->tenantId);
|
self::assertNotEmpty((string) $establishment->tenantId);
|
||||||
self::assertSame('École Alpha', $result->name);
|
self::assertSame('École Alpha', $establishment->name);
|
||||||
self::assertSame('ecole-alpha', $result->subdomain);
|
self::assertSame('ecole-alpha', $establishment->subdomain);
|
||||||
self::assertStringStartsWith('classeo_tenant_', $result->databaseName);
|
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -65,10 +65,10 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
|||||||
superAdminId: self::SUPER_ADMIN_ID,
|
superAdminId: self::SUPER_ADMIN_ID,
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = ($this->handler)($command);
|
$establishment = ($this->handler)($command);
|
||||||
|
|
||||||
$establishments = $this->repository->findAll();
|
$establishments = $this->repository->findAll();
|
||||||
self::assertCount(1, $establishments);
|
self::assertCount(1, $establishments);
|
||||||
self::assertSame($result->establishmentId, (string) $establishments[0]->id);
|
self::assertSame((string) $establishment->id, (string) $establishments[0]->id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
|||||||
$this->repository->save(Establishment::creer(
|
$this->repository->save(Establishment::creer(
|
||||||
name: 'École Alpha',
|
name: 'École Alpha',
|
||||||
subdomain: 'ecole-alpha',
|
subdomain: 'ecole-alpha',
|
||||||
|
adminEmail: 'admin@ecole-alpha.fr',
|
||||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||||
));
|
));
|
||||||
@@ -47,6 +48,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
|||||||
$this->repository->save(Establishment::creer(
|
$this->repository->save(Establishment::creer(
|
||||||
name: 'École Beta',
|
name: 'École Beta',
|
||||||
subdomain: 'ecole-beta',
|
subdomain: 'ecole-beta',
|
||||||
|
adminEmail: 'admin@ecole-beta.fr',
|
||||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
createdAt: new DateTimeImmutable('2026-02-16 11:00:00'),
|
createdAt: new DateTimeImmutable('2026-02-16 11:00:00'),
|
||||||
));
|
));
|
||||||
@@ -56,6 +58,6 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
|||||||
self::assertCount(2, $result);
|
self::assertCount(2, $result);
|
||||||
self::assertSame('École Alpha', $result[0]->name);
|
self::assertSame('École Alpha', $result[0]->name);
|
||||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||||
self::assertSame('active', $result[0]->status);
|
self::assertSame('provisioning', $result[0]->status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ final class EstablishmentTest extends TestCase
|
|||||||
private const string SUBDOMAIN = 'ecole-alpha';
|
private const string SUBDOMAIN = 'ecole-alpha';
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function creerCreatesActiveEstablishment(): void
|
public function creerCreatesProvisioningEstablishment(): void
|
||||||
{
|
{
|
||||||
$establishment = $this->createEstablishment();
|
$establishment = $this->createEstablishment();
|
||||||
|
|
||||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||||
self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name);
|
self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name);
|
||||||
self::assertSame(self::SUBDOMAIN, $establishment->subdomain);
|
self::assertSame(self::SUBDOMAIN, $establishment->subdomain);
|
||||||
self::assertNull($establishment->lastActivityAt);
|
self::assertNull($establishment->lastActivityAt);
|
||||||
@@ -59,10 +59,21 @@ final class EstablishmentTest extends TestCase
|
|||||||
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function activerChangesStatusToActif(): void
|
||||||
|
{
|
||||||
|
$establishment = $this->createEstablishment();
|
||||||
|
|
||||||
|
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||||
|
$establishment->activer();
|
||||||
|
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function desactiverChangesStatusToInactif(): void
|
public function desactiverChangesStatusToInactif(): void
|
||||||
{
|
{
|
||||||
$establishment = $this->createEstablishment();
|
$establishment = $this->createEstablishment();
|
||||||
|
$establishment->activer();
|
||||||
|
|
||||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||||
|
|
||||||
@@ -73,6 +84,7 @@ final class EstablishmentTest extends TestCase
|
|||||||
public function desactiverRecordsEtablissementDesactiveEvent(): void
|
public function desactiverRecordsEtablissementDesactiveEvent(): void
|
||||||
{
|
{
|
||||||
$establishment = $this->createEstablishment();
|
$establishment = $this->createEstablishment();
|
||||||
|
$establishment->activer();
|
||||||
$establishment->pullDomainEvents(); // Clear creation event
|
$establishment->pullDomainEvents(); // Clear creation event
|
||||||
|
|
||||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||||
@@ -86,6 +98,7 @@ final class EstablishmentTest extends TestCase
|
|||||||
public function desactiverThrowsWhenAlreadyInactive(): void
|
public function desactiverThrowsWhenAlreadyInactive(): void
|
||||||
{
|
{
|
||||||
$establishment = $this->createEstablishment();
|
$establishment = $this->createEstablishment();
|
||||||
|
$establishment->activer();
|
||||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||||
|
|
||||||
$this->expectException(EstablishmentDejaInactifException::class);
|
$this->expectException(EstablishmentDejaInactifException::class);
|
||||||
@@ -141,6 +154,7 @@ final class EstablishmentTest extends TestCase
|
|||||||
return Establishment::creer(
|
return Establishment::creer(
|
||||||
name: self::ESTABLISHMENT_NAME,
|
name: self::ESTABLISHMENT_NAME,
|
||||||
subdomain: self::SUBDOMAIN,
|
subdomain: self::SUBDOMAIN,
|
||||||
|
adminEmail: 'admin@ecole-alpha.fr',
|
||||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Shared\Domain\Clock;
|
use App\Shared\Domain\Clock;
|
||||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
@@ -16,13 +17,15 @@ use DateTimeImmutable;
|
|||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
final class CreateEstablishmentProcessorTest extends TestCase
|
final class CreateEstablishmentProcessorTest extends TestCase
|
||||||
{
|
{
|
||||||
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function processCreatesEstablishmentAndReturnsResource(): void
|
public function processCreatesEstablishmentAndDispatchesProvisioning(): void
|
||||||
{
|
{
|
||||||
$repository = new InMemoryEstablishmentRepository();
|
$repository = new InMemoryEstablishmentRepository();
|
||||||
$clock = new class implements Clock {
|
$clock = new class implements Clock {
|
||||||
@@ -42,7 +45,16 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
|||||||
$security = $this->createMock(Security::class);
|
$security = $this->createMock(Security::class);
|
||||||
$security->method('getUser')->willReturn($securityUser);
|
$security->method('getUser')->willReturn($securityUser);
|
||||||
|
|
||||||
$processor = new CreateEstablishmentProcessor($handler, $security);
|
$dispatched = [];
|
||||||
|
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$commandBus->method('dispatch')
|
||||||
|
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
|
||||||
|
$dispatched[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
$processor = new CreateEstablishmentProcessor($handler, $security, $commandBus);
|
||||||
|
|
||||||
$input = new EstablishmentResource();
|
$input = new EstablishmentResource();
|
||||||
$input->name = 'École Gamma';
|
$input->name = 'École Gamma';
|
||||||
@@ -55,6 +67,12 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
|||||||
self::assertNotNull($result->tenantId);
|
self::assertNotNull($result->tenantId);
|
||||||
self::assertSame('École Gamma', $result->name);
|
self::assertSame('École Gamma', $result->name);
|
||||||
self::assertSame('ecole-gamma', $result->subdomain);
|
self::assertSame('ecole-gamma', $result->subdomain);
|
||||||
self::assertSame('active', $result->status);
|
self::assertSame('provisioning', $result->status);
|
||||||
|
|
||||||
|
self::assertCount(1, $dispatched);
|
||||||
|
self::assertInstanceOf(ProvisionEstablishmentCommand::class, $dispatched[0]);
|
||||||
|
self::assertSame('admin@ecole-gamma.fr', $dispatched[0]->adminEmail);
|
||||||
|
self::assertSame('ecole-gamma', $dispatched[0]->subdomain);
|
||||||
|
self::assertSame('École Gamma', $dispatched[0]->establishmentName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
|||||||
$repository->save(Establishment::creer(
|
$repository->save(Establishment::creer(
|
||||||
name: 'École Alpha',
|
name: 'École Alpha',
|
||||||
subdomain: 'ecole-alpha',
|
subdomain: 'ecole-alpha',
|
||||||
|
adminEmail: 'admin@ecole-alpha.fr',
|
||||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||||
));
|
));
|
||||||
@@ -49,6 +50,6 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
|||||||
self::assertCount(1, $result);
|
self::assertCount(1, $result);
|
||||||
self::assertSame('École Alpha', $result[0]->name);
|
self::assertSame('École Alpha', $result[0]->name);
|
||||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||||
self::assertSame('active', $result[0]->status);
|
self::assertSame('provisioning', $result[0]->status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class DatabaseTenantProvisionerTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function itCallsCreatorThenMigratorInOrder(): void
|
||||||
|
{
|
||||||
|
$steps = [];
|
||||||
|
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection->method('fetchOne')->willReturn(false);
|
||||||
|
$connection->method('executeStatement')->willReturnCallback(
|
||||||
|
static function () use (&$steps): int {
|
||||||
|
$steps[] = 'create';
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||||
|
|
||||||
|
// TenantMigrator is final — we wrap via the TenantProvisioner interface
|
||||||
|
// to verify the creator is called. Migration subprocess cannot be tested unitarily.
|
||||||
|
$provisioner = new class($creator, $steps) implements TenantProvisioner {
|
||||||
|
/** @param string[] $steps */
|
||||||
|
public function __construct(
|
||||||
|
private readonly TenantDatabaseCreator $creator,
|
||||||
|
private array &$steps,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provision(string $databaseName): void
|
||||||
|
{
|
||||||
|
$this->creator->create($databaseName);
|
||||||
|
$this->steps[] = 'migrate';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$provisioner->provision('classeo_tenant_test');
|
||||||
|
|
||||||
|
self::assertSame(['create', 'migrate'], $steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPropagatesCreationFailure(): void
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection->method('fetchOne')->willThrowException(new RuntimeException('Connection refused'));
|
||||||
|
|
||||||
|
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||||
|
$migrator = new TenantMigrator('/tmp', 'postgresql://u:p@h/db', new NullLogger());
|
||||||
|
|
||||||
|
$provisioner = new DatabaseTenantProvisioner($creator, $migrator);
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$provisioner->provision('classeo_tenant_test');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||||
|
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
final class ProvisionEstablishmentHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master?serverVersion=18';
|
||||||
|
private const string ESTABLISHMENT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itProvisionsTenantDatabase(): void
|
||||||
|
{
|
||||||
|
$provisioner = $this->createMock(TenantProvisioner::class);
|
||||||
|
$provisioner->expects(self::once())
|
||||||
|
->method('provision')
|
||||||
|
->with('classeo_tenant_abc123');
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(provisioner: $provisioner);
|
||||||
|
$handler($this->command());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesAdminUser(): void
|
||||||
|
{
|
||||||
|
$userRepository = new InMemoryUserRepository();
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(userRepository: $userRepository);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
$users = $userRepository->findAllByTenant(TenantId::fromString(self::TENANT_ID));
|
||||||
|
self::assertCount(1, $users);
|
||||||
|
self::assertSame('admin@ecole-gamma.fr', (string) $users[0]->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itDispatchesInvitationEvent(): void
|
||||||
|
{
|
||||||
|
$dispatched = [];
|
||||||
|
$eventBus = $this->spyEventBus($dispatched);
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(eventBus: $eventBus);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
self::assertNotEmpty($dispatched);
|
||||||
|
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itActivatesEstablishmentAfterProvisioning(): void
|
||||||
|
{
|
||||||
|
$establishmentRepo = $this->establishmentRepoWithProvisioningEstablishment();
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(establishmentRepository: $establishmentRepo);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
$establishment = $establishmentRepo->get(
|
||||||
|
EstablishmentId::fromString(self::ESTABLISHMENT_ID),
|
||||||
|
);
|
||||||
|
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itIsIdempotentWhenAdminAlreadyExists(): void
|
||||||
|
{
|
||||||
|
$userRepository = new InMemoryUserRepository();
|
||||||
|
$dispatched = [];
|
||||||
|
$eventBus = $this->spyEventBus($dispatched);
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(userRepository: $userRepository, eventBus: $eventBus);
|
||||||
|
|
||||||
|
// First call creates the admin
|
||||||
|
$handler($this->command());
|
||||||
|
self::assertCount(1, $dispatched);
|
||||||
|
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
|
||||||
|
|
||||||
|
// Second call is idempotent — re-sends invitation
|
||||||
|
$dispatched = [];
|
||||||
|
$handler($this->command());
|
||||||
|
self::assertCount(1, $dispatched);
|
||||||
|
self::assertInstanceOf(InvitationRenvoyee::class, $dispatched[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itSwitchesDatabaseAndRestores(): void
|
||||||
|
{
|
||||||
|
$switcher = new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(databaseSwitcher: $switcher);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
self::assertCount(1, $switcher->switchedTo);
|
||||||
|
self::assertStringContainsString('classeo_tenant_abc123', $switcher->switchedTo[0]);
|
||||||
|
self::assertTrue($switcher->restoredToDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPreservesQueryParametersInDatabaseUrl(): void
|
||||||
|
{
|
||||||
|
$switcher = new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(databaseSwitcher: $switcher);
|
||||||
|
$handler($this->command());
|
||||||
|
|
||||||
|
self::assertStringContainsString('?serverVersion=18', $switcher->switchedTo[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itRestoresDatabaseEvenOnFailure(): void
|
||||||
|
{
|
||||||
|
$switcher = new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$eventBus->method('dispatch')
|
||||||
|
->willThrowException(new RuntimeException('Event bus failure'));
|
||||||
|
|
||||||
|
$handler = $this->buildHandler(databaseSwitcher: $switcher, eventBus: $eventBus);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$handler($this->command());
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertTrue($switcher->restoredToDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function command(): ProvisionEstablishmentCommand
|
||||||
|
{
|
||||||
|
return new ProvisionEstablishmentCommand(
|
||||||
|
establishmentId: self::ESTABLISHMENT_ID,
|
||||||
|
establishmentTenantId: self::TENANT_ID,
|
||||||
|
databaseName: 'classeo_tenant_abc123',
|
||||||
|
subdomain: 'ecole-gamma',
|
||||||
|
adminEmail: 'admin@ecole-gamma.fr',
|
||||||
|
establishmentName: 'École Gamma',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function establishmentRepoWithProvisioningEstablishment(): InMemoryEstablishmentRepository
|
||||||
|
{
|
||||||
|
$repo = new InMemoryEstablishmentRepository();
|
||||||
|
$establishment = Establishment::reconstitute(
|
||||||
|
id: EstablishmentId::fromString(self::ESTABLISHMENT_ID),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
name: 'École Gamma',
|
||||||
|
subdomain: 'ecole-gamma',
|
||||||
|
databaseName: 'classeo_tenant_abc123',
|
||||||
|
status: EstablishmentStatus::PROVISIONING,
|
||||||
|
createdAt: new DateTimeImmutable('2026-04-07 10:00:00'),
|
||||||
|
createdBy: SuperAdminId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||||
|
);
|
||||||
|
$repo->save($establishment);
|
||||||
|
|
||||||
|
return $repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param object[] $dispatched
|
||||||
|
*/
|
||||||
|
private function spyEventBus(array &$dispatched): MessageBusInterface
|
||||||
|
{
|
||||||
|
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$eventBus->method('dispatch')
|
||||||
|
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
|
||||||
|
$dispatched[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $eventBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildHandler(
|
||||||
|
?TenantProvisioner $provisioner = null,
|
||||||
|
?InMemoryUserRepository $userRepository = null,
|
||||||
|
?SpyDatabaseSwitcher $databaseSwitcher = null,
|
||||||
|
?InMemoryEstablishmentRepository $establishmentRepository = null,
|
||||||
|
?MessageBusInterface $eventBus = null,
|
||||||
|
): ProvisionEstablishmentHandler {
|
||||||
|
$provisioner ??= $this->createMock(TenantProvisioner::class);
|
||||||
|
|
||||||
|
$clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-04-07 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$userRepository ??= new InMemoryUserRepository();
|
||||||
|
|
||||||
|
$databaseSwitcher ??= new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$establishmentRepository ??= $this->establishmentRepoWithProvisioningEstablishment();
|
||||||
|
|
||||||
|
$eventBus ??= $this->createMock(MessageBusInterface::class);
|
||||||
|
$eventBus->method('dispatch')
|
||||||
|
->willReturnCallback(static fn (object $m): Envelope => new Envelope($m));
|
||||||
|
|
||||||
|
return new ProvisionEstablishmentHandler(
|
||||||
|
tenantProvisioner: $provisioner,
|
||||||
|
inviteUserHandler: new InviteUserHandler($userRepository, $clock),
|
||||||
|
userRepository: $userRepository,
|
||||||
|
clock: $clock,
|
||||||
|
databaseSwitcher: $databaseSwitcher,
|
||||||
|
establishmentRepository: $establishmentRepository,
|
||||||
|
eventBus: $eventBus,
|
||||||
|
logger: new NullLogger(),
|
||||||
|
masterDatabaseUrl: self::MASTER_URL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||||
|
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
|
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||||
|
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests: verify the full provisioning flow from API request
|
||||||
|
* through establishment creation to async provisioning and admin user creation.
|
||||||
|
*
|
||||||
|
* Split into focused tests that each verify one aspect of the flow.
|
||||||
|
*/
|
||||||
|
final class ProvisioningIntegrationTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||||
|
|
||||||
|
private InMemoryEstablishmentRepository $establishmentRepository;
|
||||||
|
private InMemoryUserRepository $userRepository;
|
||||||
|
private ?ProvisionEstablishmentCommand $provisionCommand;
|
||||||
|
/** @var object[] */
|
||||||
|
private array $dispatchedEvents;
|
||||||
|
|
||||||
|
private function runFullFlow(): void
|
||||||
|
{
|
||||||
|
$clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-04-07 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 1: API processor creates establishment
|
||||||
|
$this->establishmentRepository = new InMemoryEstablishmentRepository();
|
||||||
|
$createHandler = new CreateEstablishmentHandler($this->establishmentRepository, $clock);
|
||||||
|
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('getUser')->willReturn(new SecuritySuperAdmin(
|
||||||
|
SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
|
'superadmin@classeo.fr',
|
||||||
|
'hashed',
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->provisionCommand = null;
|
||||||
|
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$commandBus->method('dispatch')
|
||||||
|
->willReturnCallback(function (object $message): Envelope {
|
||||||
|
if ($message instanceof ProvisionEstablishmentCommand) {
|
||||||
|
$this->provisionCommand = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
$processor = new CreateEstablishmentProcessor($createHandler, $security, $commandBus);
|
||||||
|
|
||||||
|
$input = new EstablishmentResource();
|
||||||
|
$input->name = 'École Test';
|
||||||
|
$input->subdomain = 'ecole-test';
|
||||||
|
$input->adminEmail = 'admin@ecole-test.fr';
|
||||||
|
|
||||||
|
$processor->process($input, new Post());
|
||||||
|
|
||||||
|
// Phase 2: Provisioning handler processes the command
|
||||||
|
self::assertNotNull($this->provisionCommand);
|
||||||
|
|
||||||
|
$this->userRepository = new InMemoryUserRepository();
|
||||||
|
$this->dispatchedEvents = [];
|
||||||
|
|
||||||
|
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||||
|
$eventBus->method('dispatch')
|
||||||
|
->willReturnCallback(function (object $message): Envelope {
|
||||||
|
$this->dispatchedEvents[] = $message;
|
||||||
|
|
||||||
|
return new Envelope($message);
|
||||||
|
});
|
||||||
|
|
||||||
|
$provisioner = $this->createMock(TenantProvisioner::class);
|
||||||
|
|
||||||
|
$switcher = new SpyDatabaseSwitcher();
|
||||||
|
|
||||||
|
$provisionHandler = new ProvisionEstablishmentHandler(
|
||||||
|
tenantProvisioner: $provisioner,
|
||||||
|
inviteUserHandler: new InviteUserHandler($this->userRepository, $clock),
|
||||||
|
userRepository: $this->userRepository,
|
||||||
|
clock: $clock,
|
||||||
|
databaseSwitcher: $switcher,
|
||||||
|
establishmentRepository: $this->establishmentRepository,
|
||||||
|
eventBus: $eventBus,
|
||||||
|
logger: new NullLogger(),
|
||||||
|
masterDatabaseUrl: self::MASTER_URL,
|
||||||
|
);
|
||||||
|
|
||||||
|
$provisionHandler($this->provisionCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function processorCreatesEstablishmentInProvisioningStatus(): void
|
||||||
|
{
|
||||||
|
$this->runFullFlow();
|
||||||
|
|
||||||
|
$establishments = $this->establishmentRepository->findAll();
|
||||||
|
self::assertCount(1, $establishments);
|
||||||
|
self::assertSame('École Test', $establishments[0]->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function processorDispatchesProvisioningCommandWithAdminEmail(): void
|
||||||
|
{
|
||||||
|
$this->runFullFlow();
|
||||||
|
|
||||||
|
self::assertNotNull($this->provisionCommand);
|
||||||
|
self::assertSame('admin@ecole-test.fr', $this->provisionCommand->adminEmail);
|
||||||
|
self::assertSame('ecole-test', $this->provisionCommand->subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function provisioningCreatesAdminUserWithCorrectRole(): void
|
||||||
|
{
|
||||||
|
$this->runFullFlow();
|
||||||
|
|
||||||
|
$users = $this->userRepository->findAllByTenant(
|
||||||
|
TenantId::fromString($this->provisionCommand->establishmentTenantId),
|
||||||
|
);
|
||||||
|
self::assertCount(1, $users);
|
||||||
|
self::assertSame('admin@ecole-test.fr', (string) $users[0]->email);
|
||||||
|
self::assertSame(Role::ADMIN, $users[0]->role);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function provisioningActivatesEstablishmentAndDispatchesEvent(): void
|
||||||
|
{
|
||||||
|
$this->runFullFlow();
|
||||||
|
|
||||||
|
$establishments = $this->establishmentRepository->findAll();
|
||||||
|
self::assertSame(EstablishmentStatus::ACTIF, $establishments[0]->status);
|
||||||
|
|
||||||
|
self::assertCount(1, $this->dispatchedEvents);
|
||||||
|
self::assertInstanceOf(UtilisateurInvite::class, $this->dispatchedEvents[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test double that records database switching operations.
|
||||||
|
*/
|
||||||
|
final class SpyDatabaseSwitcher implements TenantDatabaseSwitcher
|
||||||
|
{
|
||||||
|
/** @var string[] */
|
||||||
|
public array $switchedTo = [];
|
||||||
|
public bool $restoredToDefault = false;
|
||||||
|
|
||||||
|
public function useTenantDatabase(string $databaseUrl): void
|
||||||
|
{
|
||||||
|
$this->switchedTo[] = $databaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function useDefaultDatabase(): void
|
||||||
|
{
|
||||||
|
$this->restoredToDefault = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentDatabaseUrl(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
compose.yaml
40
compose.yaml
@@ -39,6 +39,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/docs"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/docs"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -221,6 +223,43 @@ services:
|
|||||||
memory: 256M
|
memory: 256M
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OBJECT STORAGE - MinIO (S3-compatible)
|
||||||
|
# =============================================================================
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: classeo_minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: classeo
|
||||||
|
MINIO_ROOT_PASSWORD: classeo_secret
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Init container: creates the S3 bucket if it doesn't exist
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc
|
||||||
|
container_name: classeo_minio_init
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
mc alias set local http://minio:9000 classeo classeo_secret &&
|
||||||
|
mc mb --ignore-existing local/classeo
|
||||||
|
"
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# EMAIL TESTING - Mailpit
|
# EMAIL TESTING - Mailpit
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -246,6 +285,7 @@ volumes:
|
|||||||
redis_data:
|
redis_data:
|
||||||
rabbitmq_data:
|
rabbitmq_data:
|
||||||
meilisearch_data:
|
meilisearch_data:
|
||||||
|
minio_data:
|
||||||
frontend_node_modules:
|
frontend_node_modules:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
{$APP_DOMAIN} {
|
# Domaine principal et sous-domaines wildcard (multi-tenant)
|
||||||
|
# Caddy provisionne automatiquement les certificats TLS via Let's Encrypt.
|
||||||
|
# Le wildcard nécessite un DNS challenge : configurer CADDY_DNS_PROVIDER
|
||||||
|
# et les credentials DNS dans les variables d'environnement.
|
||||||
|
|
||||||
|
{$APP_DOMAIN}, *.{$APP_DOMAIN} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Le certificat wildcard nécessite un DNS challenge
|
||||||
|
tls {
|
||||||
|
dns {$CADDY_DNS_PROVIDER:cloudflare} {$CADDY_DNS_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy php:8000
|
reverse_proxy php:8000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
|||||||
await firstInput.fill('25');
|
await firstInput.fill('25');
|
||||||
|
|
||||||
// Should show error
|
// Should show error
|
||||||
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 });
|
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const urlMatch = baseUrl.match(/:(\d+)$/);
|
|||||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
// Réutilise le même enseignant que homework.spec.ts pour partager le setup
|
|
||||||
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
||||||
const TEACHER_PASSWORD = 'HomeworkTest123';
|
const TEACHER_PASSWORD = 'HomeworkTest123';
|
||||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
@@ -105,7 +104,6 @@ async function createHomework(page: import('@playwright/test').Page, title: stri
|
|||||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
await page.locator('#hw-title').fill(title);
|
await page.locator('#hw-title').fill(title);
|
||||||
|
|
||||||
// Type in WYSIWYG editor (TipTap initializes asynchronously)
|
|
||||||
const editorContent = page.locator('.modal .rich-text-content');
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||||
await editorContent.click();
|
await editorContent.click();
|
||||||
@@ -158,9 +156,8 @@ function cleanupTempFiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
test.describe('Homework Attachments (Story 5.9/5.11)', () => {
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
// Ensure teacher user exists (same as homework.spec.ts)
|
|
||||||
execSync(
|
execSync(
|
||||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
@@ -189,7 +186,6 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
// homework_submissions has NO CASCADE on homework_id — delete submissions first
|
|
||||||
try {
|
try {
|
||||||
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||||
} catch { /* Table may not exist */ }
|
} catch { /* Table may not exist */ }
|
||||||
@@ -198,138 +194,41 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
|||||||
} catch { /* Table may not exist */ }
|
} catch { /* Table may not exist */ }
|
||||||
try {
|
try {
|
||||||
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
||||||
} catch {
|
} catch { /* Table may not exist */ }
|
||||||
// Table may not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable any homework rules left by other test files (homework-rules-warning,
|
|
||||||
// homework-rules-hard) to prevent rule warnings blocking homework creation.
|
|
||||||
try {
|
try {
|
||||||
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
||||||
} catch { /* Table may not exist */ }
|
} catch { /* Table may not exist */ }
|
||||||
|
|
||||||
// Clear school calendar entries that may block dates (Vacances de Printemps, etc.)
|
|
||||||
try {
|
try {
|
||||||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||||
} catch { /* Table may not exist */ }
|
} catch { /* Table may not exist */ }
|
||||||
|
|
||||||
clearCache();
|
clearCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
test.describe('Upload & Delete', () => {
|
||||||
// T4.1 : WYSIWYG Editor
|
|
||||||
// ============================================================================
|
|
||||||
test.describe('WYSIWYG Editor', () => {
|
|
||||||
test('create form shows rich text editor with toolbar', async ({ page }) => {
|
|
||||||
await loginAsTeacher(page);
|
|
||||||
await navigateToHomework(page);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Rich text editor with toolbar should be visible
|
|
||||||
const editor = page.locator('.rich-text-editor');
|
|
||||||
await expect(editor).toBeVisible({ timeout: 5000 });
|
|
||||||
await expect(page.locator('.toolbar')).toBeVisible();
|
|
||||||
|
|
||||||
// Toolbar buttons
|
|
||||||
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('can create homework with rich text description', async ({ page }) => {
|
|
||||||
await loginAsTeacher(page);
|
|
||||||
await navigateToHomework(page);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
|
||||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
|
||||||
await page.locator('#hw-title').fill('Devoir texte riche');
|
|
||||||
|
|
||||||
// Type in rich text editor (TipTap initializes asynchronously)
|
|
||||||
const editorContent = page.locator('.modal .rich-text-content');
|
|
||||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
|
||||||
await editorContent.click();
|
|
||||||
await page.keyboard.type('Consignes importantes');
|
|
||||||
|
|
||||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
|
||||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bold formatting works in editor', async ({ page }) => {
|
|
||||||
await loginAsTeacher(page);
|
|
||||||
await navigateToHomework(page);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
|
||||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
|
||||||
await page.locator('#hw-title').fill('Devoir gras test');
|
|
||||||
|
|
||||||
const editorContent = page.locator('.modal .rich-text-content');
|
|
||||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
|
||||||
await editorContent.click();
|
|
||||||
await page.keyboard.type('Normal ');
|
|
||||||
|
|
||||||
// Apply bold via keyboard shortcut (more reliable than toolbar click)
|
|
||||||
await page.keyboard.press('Control+b');
|
|
||||||
await page.keyboard.type('en gras');
|
|
||||||
|
|
||||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
|
||||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Verify bold is rendered in the description
|
|
||||||
const description = page.locator('.homework-description');
|
|
||||||
await expect(description.locator('strong')).toContainText('en gras');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// T4.2 : Upload attachment
|
|
||||||
// ============================================================================
|
|
||||||
test.describe('Attachments', () => {
|
|
||||||
test('can upload a PDF attachment to homework via edit modal', async ({ page }) => {
|
test('can upload a PDF attachment to homework via edit modal', async ({ page }) => {
|
||||||
await loginAsTeacher(page);
|
await loginAsTeacher(page);
|
||||||
await navigateToHomework(page);
|
await navigateToHomework(page);
|
||||||
|
|
||||||
// Create homework
|
|
||||||
await createHomework(page, 'Devoir avec PJ');
|
await createHomework(page, 'Devoir avec PJ');
|
||||||
|
|
||||||
// Open edit modal
|
|
||||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' });
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' });
|
||||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Upload file
|
|
||||||
const pdfPath = createTempPdf();
|
const pdfPath = createTempPdf();
|
||||||
const fileInput = page.locator('.file-input-hidden');
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
await fileInput.setInputFiles(pdfPath);
|
await fileInput.setInputFiles(pdfPath);
|
||||||
|
|
||||||
// File appears in list
|
|
||||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// T4.3 : Delete attachment
|
|
||||||
test('can delete an uploaded attachment', async ({ page }) => {
|
test('can delete an uploaded attachment', async ({ page }) => {
|
||||||
test.slow(); // upload + delete needs more than 30s
|
test.slow();
|
||||||
await loginAsTeacher(page);
|
await loginAsTeacher(page);
|
||||||
await navigateToHomework(page);
|
await navigateToHomework(page);
|
||||||
|
|
||||||
await createHomework(page, 'Devoir suppr PJ');
|
await createHomework(page, 'Devoir suppr PJ');
|
||||||
|
|
||||||
// Open edit modal and upload
|
|
||||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' });
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' });
|
||||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
@@ -338,80 +237,142 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
|||||||
await page.locator('.file-input-hidden').setInputFiles(pdfPath);
|
await page.locator('.file-input-hidden').setInputFiles(pdfPath);
|
||||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// Delete the attachment
|
|
||||||
await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click();
|
await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click();
|
||||||
await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 });
|
await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
test.describe('Drop Zone UI', () => {
|
||||||
// T5.9.1 : Invalid file type rejection (P1)
|
test('create form shows drag-and-drop zone for attachments', async ({ page }) => {
|
||||||
// ============================================================================
|
await loginAsTeacher(page);
|
||||||
test.describe('Invalid File Type Rejection', () => {
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await createHomework(page, 'Devoir drop zone');
|
||||||
|
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir drop zone' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const dropZone = page.locator('.drop-zone');
|
||||||
|
await expect(dropZone).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(dropZone).toContainText('Glissez-déposez');
|
||||||
|
await expect(dropZone.locator('.drop-zone-browse')).toContainText('parcourir');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browse button in drop zone opens file dialog and uploads', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await createHomework(page, 'Devoir browse btn');
|
||||||
|
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir browse btn' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const pdfPath = createTempPdf();
|
||||||
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
|
await fileInput.setInputFiles(pdfPath);
|
||||||
|
|
||||||
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drag-and-drop visual feedback appears on dragover', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await createHomework(page, 'Devoir drag feedback');
|
||||||
|
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir drag feedback' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const dropZone = page.locator('.drop-zone');
|
||||||
|
await expect(dropZone).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
await dropZone.evaluate((el) => {
|
||||||
|
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: new DataTransfer(), bubbles: true }));
|
||||||
|
});
|
||||||
|
await expect(dropZone).toHaveClass(/drop-zone-active/);
|
||||||
|
|
||||||
|
await dropZone.evaluate((el) => {
|
||||||
|
el.dispatchEvent(new DragEvent('dragleave', { bubbles: true }));
|
||||||
|
});
|
||||||
|
await expect(dropZone).not.toHaveClass(/drop-zone-active/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('File Type Badge', () => {
|
||||||
|
test('uploaded PDF shows formatted type badge', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await createHomework(page, 'Devoir badge type');
|
||||||
|
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir badge type' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const pdfPath = createTempPdf();
|
||||||
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
|
await fileInput.setInputFiles(pdfPath);
|
||||||
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const fileType = page.locator('.file-type');
|
||||||
|
await expect(fileType).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(fileType).toHaveText('PDF');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Validation', () => {
|
||||||
test('rejects a .txt file with an error message', async ({ page }) => {
|
test('rejects a .txt file with an error message', async ({ page }) => {
|
||||||
await loginAsTeacher(page);
|
await loginAsTeacher(page);
|
||||||
await navigateToHomework(page);
|
await navigateToHomework(page);
|
||||||
|
|
||||||
await createHomework(page, 'Devoir rejet fichier');
|
await createHomework(page, 'Devoir rejet fichier');
|
||||||
|
|
||||||
// Open edit modal
|
|
||||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' });
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' });
|
||||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Try to upload a .txt file
|
|
||||||
const txtPath = createTempTxt();
|
const txtPath = createTempTxt();
|
||||||
const fileInput = page.locator('.file-input-hidden');
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
await fileInput.setInputFiles(txtPath);
|
await fileInput.setInputFiles(txtPath);
|
||||||
|
|
||||||
// Error message should appear
|
|
||||||
const errorAlert = page.locator('[role="alert"]');
|
const errorAlert = page.locator('[role="alert"]');
|
||||||
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
||||||
await expect(errorAlert).toContainText('Type de fichier non accepté');
|
await expect(errorAlert).toContainText('Type de fichier non accepté');
|
||||||
|
|
||||||
// The .txt file should NOT appear in the file list
|
|
||||||
await expect(page.getByText('test-invalid.txt')).not.toBeVisible();
|
await expect(page.getByText('test-invalid.txt')).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
test.describe('Persistence', () => {
|
||||||
// T5.9.2 : Attachment persistence after save (P1)
|
|
||||||
// ============================================================================
|
|
||||||
test.describe('Attachment Persistence', () => {
|
|
||||||
test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => {
|
test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => {
|
||||||
await loginAsTeacher(page);
|
await loginAsTeacher(page);
|
||||||
await navigateToHomework(page);
|
await navigateToHomework(page);
|
||||||
|
|
||||||
await createHomework(page, 'Devoir persistance PJ');
|
await createHomework(page, 'Devoir persistance PJ');
|
||||||
|
|
||||||
// Open edit modal
|
|
||||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Upload a PDF
|
|
||||||
const pdfPath = createTempPdf();
|
const pdfPath = createTempPdf();
|
||||||
const fileInput = page.locator('.file-input-hidden');
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
await fileInput.setInputFiles(pdfPath);
|
await fileInput.setInputFiles(pdfPath);
|
||||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// Save the changes
|
|
||||||
await page.getByRole('button', { name: /enregistrer/i }).click();
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Reopen the edit modal
|
|
||||||
const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||||
await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click();
|
await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// The attachment should still be there
|
|
||||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// T5.9.3 : File size display after upload (P2)
|
|
||||||
// ============================================================================
|
|
||||||
test.describe('File Size Display', () => {
|
test.describe('File Size Display', () => {
|
||||||
test('shows formatted file size after uploading a PDF', async ({ page }) => {
|
test('shows formatted file size after uploading a PDF', async ({ page }) => {
|
||||||
await loginAsTeacher(page);
|
await loginAsTeacher(page);
|
||||||
@@ -419,73 +380,18 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
|||||||
|
|
||||||
await createHomework(page, 'Devoir taille fichier');
|
await createHomework(page, 'Devoir taille fichier');
|
||||||
|
|
||||||
// Open edit modal
|
|
||||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' });
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' });
|
||||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Upload a PDF
|
|
||||||
const pdfPath = createTempPdf();
|
const pdfPath = createTempPdf();
|
||||||
const fileInput = page.locator('.file-input-hidden');
|
const fileInput = page.locator('.file-input-hidden');
|
||||||
await fileInput.setInputFiles(pdfPath);
|
await fileInput.setInputFiles(pdfPath);
|
||||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// The file size element should be visible and show a formatted size (e.g., "xxx o" or "xxx Ko")
|
|
||||||
const fileSize = page.locator('.file-size');
|
const fileSize = page.locator('.file-size');
|
||||||
await expect(fileSize).toBeVisible({ timeout: 5000 });
|
await expect(fileSize).toBeVisible({ timeout: 5000 });
|
||||||
await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/);
|
await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// T4.4 : Backward compatibility
|
|
||||||
// ============================================================================
|
|
||||||
test.describe('Backward Compatibility', () => {
|
|
||||||
test('existing plain text homework displays correctly', async ({ page }) => {
|
|
||||||
// Create homework with plain text description via SQL
|
|
||||||
runSql(
|
|
||||||
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
|
||||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
|
||||||
`FROM users u, school_classes c, subjects s ` +
|
|
||||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
|
||||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
|
||||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
|
||||||
`LIMIT 1`
|
|
||||||
);
|
|
||||||
clearCache();
|
|
||||||
|
|
||||||
await loginAsTeacher(page);
|
|
||||||
await navigateToHomework(page);
|
|
||||||
|
|
||||||
// Plain text description displays correctly
|
|
||||||
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
|
|
||||||
runSql(
|
|
||||||
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
|
||||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
|
||||||
`FROM users u, school_classes c, subjects s ` +
|
|
||||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
|
||||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
|
||||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
|
||||||
`LIMIT 1`
|
|
||||||
);
|
|
||||||
clearCache();
|
|
||||||
|
|
||||||
await loginAsTeacher(page);
|
|
||||||
await navigateToHomework(page);
|
|
||||||
|
|
||||||
// Open edit modal
|
|
||||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
|
|
||||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// WYSIWYG editor contains the old text (TipTap initializes asynchronously)
|
|
||||||
const editorContent = page.locator('.modal .rich-text-content');
|
|
||||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
395
frontend/e2e/homework-calendar.spec.ts
Normal file
395
frontend/e2e/homework-calendar.spec.ts
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
||||||
|
const TEACHER_PASSWORD = 'HomeworkTest123';
|
||||||
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
function runSql(sql: string) {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Cache pool may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||||
|
const output = execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||||
|
`require "/app/vendor/autoload.php"; ` +
|
||||||
|
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||||
|
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||||
|
`' 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
).trim();
|
||||||
|
const [schoolId, academicYearId] = output.split('\n');
|
||||||
|
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextWeekday(daysFromNow: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + daysFromNow);
|
||||||
|
const day = date.getDay();
|
||||||
|
if (day === 0) date.setDate(date.getDate() + 1);
|
||||||
|
if (day === 6) date.setDate(date.getDate() + 2);
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTeacherAssignments() {
|
||||||
|
const { academicYearId } = resolveDeterministicIds();
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
|
||||||
|
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND c.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND s.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||||
|
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||||
|
page.getByRole('button', { name: /se connecter/i }).click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToHomework(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
|
||||||
|
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Calendar Date Picker (Story 5.11)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
seedTeacherAssignments();
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
try {
|
||||||
|
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create form shows calendar date picker instead of native input', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Calendar date picker should be visible
|
||||||
|
const picker = page.locator('.calendar-date-picker');
|
||||||
|
await expect(picker).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Trigger button should show placeholder text
|
||||||
|
await expect(picker.locator('.picker-trigger')).toContainText('Choisir une date');
|
||||||
|
|
||||||
|
// Native date input is sr-only (hidden but present for form semantics)
|
||||||
|
await expect(page.locator('#hw-due-date')).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking picker opens calendar dropdown', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const picker = page.locator('.modal .calendar-date-picker');
|
||||||
|
await picker.locator('.picker-trigger').click();
|
||||||
|
|
||||||
|
// Calendar dropdown should be visible
|
||||||
|
const dropdown = picker.locator('.calendar-dropdown');
|
||||||
|
await expect(dropdown).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Day names header should be visible
|
||||||
|
await expect(dropdown.locator('.day-name').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Month label should be visible
|
||||||
|
await expect(dropdown.locator('.month-label')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weekends are disabled in calendar', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const picker = page.locator('.modal .calendar-date-picker');
|
||||||
|
await picker.locator('.picker-trigger').click();
|
||||||
|
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Weekend cells should have the weekend class and be disabled
|
||||||
|
const weekendCells = picker.locator('.day-cell.weekend');
|
||||||
|
const count = await weekendCells.count();
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Weekend cells should be disabled
|
||||||
|
const firstWeekend = weekendCells.first();
|
||||||
|
await expect(firstWeekend).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can select a date by clicking a day in the calendar', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill('Devoir calendrier test');
|
||||||
|
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||||
|
await editorContent.click();
|
||||||
|
await page.keyboard.type('Test description');
|
||||||
|
|
||||||
|
const picker = page.locator('.modal .calendar-date-picker');
|
||||||
|
await picker.locator('.picker-trigger').click();
|
||||||
|
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Find a valid weekday button (not disabled, not weekend, not before-min)
|
||||||
|
const validDays = picker.locator('.day-cell:not(.weekend):not(.blocked):not(.before-min):not(.empty):enabled');
|
||||||
|
const validCount = await validDays.count();
|
||||||
|
expect(validCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Click the last available day (likely to be far enough in the future)
|
||||||
|
await validDays.last().click();
|
||||||
|
|
||||||
|
// Dropdown should close
|
||||||
|
await expect(picker.locator('.calendar-dropdown')).not.toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Trigger should now show the selected date (not placeholder)
|
||||||
|
await expect(picker.locator('.picker-value')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can navigate to next/previous month', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const picker = page.locator('.modal .calendar-date-picker');
|
||||||
|
await picker.locator('.picker-trigger').click();
|
||||||
|
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
const monthLabel = picker.locator('.month-label');
|
||||||
|
const initialMonth = await monthLabel.textContent();
|
||||||
|
|
||||||
|
// Navigate to next month
|
||||||
|
await picker.getByRole('button', { name: /mois suivant/i }).click();
|
||||||
|
const nextMonth = await monthLabel.textContent();
|
||||||
|
expect(nextMonth).not.toBe(initialMonth);
|
||||||
|
|
||||||
|
// Navigate back
|
||||||
|
await picker.getByRole('button', { name: /mois précédent/i }).click();
|
||||||
|
const backMonth = await monthLabel.textContent();
|
||||||
|
expect(backMonth).toBe(initialMonth);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can create homework using calendar date picker (hidden input fallback)', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill('Devoir via calendrier');
|
||||||
|
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||||
|
await editorContent.click();
|
||||||
|
await page.keyboard.type('Description du devoir');
|
||||||
|
|
||||||
|
// Use hidden date input for programmatic date selection (E2E compatibility)
|
||||||
|
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Devoir via calendrier')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit modal shows calendar date picker with current date selected', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
// Create homework first via hidden input
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill('Devoir edit calendrier');
|
||||||
|
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||||
|
await editorContent.click();
|
||||||
|
await page.keyboard.type('Description');
|
||||||
|
|
||||||
|
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit calendrier' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Edit modal should have calendar picker with date displayed
|
||||||
|
const editPicker = page.locator('.modal .calendar-date-picker');
|
||||||
|
await expect(editPicker).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(editPicker.locator('.picker-value')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rule-hard blocked dates show colored dots in calendar', async ({ page }) => {
|
||||||
|
// Seed a homework rule (minimum_delay=30 days, hard mode) so dates within 30 days are blocked.
|
||||||
|
// Using 30 days ensures the current month always has blocked weekdays visible.
|
||||||
|
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":30}}]';
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'hard', true, NOW(), NOW()) ` +
|
||||||
|
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()`
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const picker = page.locator('.modal .calendar-date-picker');
|
||||||
|
await picker.locator('.picker-trigger').click();
|
||||||
|
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Navigate to next month to guarantee blocked dates are visible
|
||||||
|
await picker.getByRole('button', { name: /mois suivant/i }).click();
|
||||||
|
|
||||||
|
// Days within the 30-day delay window should be rule-blocked
|
||||||
|
const blockedWithDot = picker.locator('.day-cell.blocked .blocked-dot');
|
||||||
|
await expect(blockedWithDot.first()).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Legend should show "Règle (bloquant)"
|
||||||
|
await expect(picker.locator('.calendar-legend')).toContainText('Règle (bloquant)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocked dates (holidays) show colored dots in calendar', async ({ page }) => {
|
||||||
|
// Seed a holiday entry covering a guaranteed weekday next month
|
||||||
|
const { academicYearId } = resolveDeterministicIds();
|
||||||
|
const nextMonth = new Date();
|
||||||
|
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||||
|
// Start from the 10th and find the first weekday (Mon-Fri)
|
||||||
|
let holidayDay = 10;
|
||||||
|
const probe = new Date(nextMonth.getFullYear(), nextMonth.getMonth(), holidayDay);
|
||||||
|
while (probe.getDay() === 0 || probe.getDay() === 6) {
|
||||||
|
holidayDay++;
|
||||||
|
probe.setDate(holidayDay);
|
||||||
|
}
|
||||||
|
const holidayDate = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth() + 1).padStart(2, '0')}-${String(holidayDay).padStart(2, '0')}`;
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, description, created_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', 'holiday', '${holidayDate}', '${holidayDate}', 'Jour férié E2E', NULL, NOW())`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const picker = page.locator('.modal .calendar-date-picker');
|
||||||
|
await picker.locator('.picker-trigger').click();
|
||||||
|
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Navigate to next month
|
||||||
|
await picker.getByRole('button', { name: /mois suivant/i }).click();
|
||||||
|
|
||||||
|
// The holiday day should be blocked with a colored dot
|
||||||
|
const holidayCell = picker.getByRole('gridcell', { name: String(holidayDay), exact: true });
|
||||||
|
await expect(holidayCell).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(holidayCell).toBeDisabled();
|
||||||
|
await expect(holidayCell.locator('.blocked-dot')).toBeVisible();
|
||||||
|
|
||||||
|
// Legend should show "Jour férié"
|
||||||
|
await expect(picker.locator('.calendar-legend')).toContainText('Jour férié');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -297,8 +297,8 @@ test.describe('Homework Exception Request (Story 5.6)', () => {
|
|||||||
.fill('Justification suffisamment longue pour être valide.');
|
.fill('Justification suffisamment longue pour être valide.');
|
||||||
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
||||||
|
|
||||||
// Wait for homework to appear
|
// Wait for homework to appear (Firefox needs more time after exception flow)
|
||||||
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
|
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
// Exception badge visible (⚠ Exception text or rule override badge)
|
// Exception badge visible (⚠ Exception text or rule override badge)
|
||||||
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
|
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
|
||||||
|
|||||||
316
frontend/e2e/homework-wysiwyg.spec.ts
Normal file
316
frontend/e2e/homework-wysiwyg.spec.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
||||||
|
const TEACHER_PASSWORD = 'HomeworkTest123';
|
||||||
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
function runSql(sql: string) {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Cache pool may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||||
|
const output = execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||||
|
`require "/app/vendor/autoload.php"; ` +
|
||||||
|
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||||
|
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||||
|
`' 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
).trim();
|
||||||
|
const [schoolId, academicYearId] = output.split('\n');
|
||||||
|
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextWeekday(daysFromNow: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + daysFromNow);
|
||||||
|
const day = date.getDay();
|
||||||
|
if (day === 0) date.setDate(date.getDate() + 1);
|
||||||
|
if (day === 6) date.setDate(date.getDate() + 2);
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTeacherAssignments() {
|
||||||
|
const { academicYearId } = resolveDeterministicIds();
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
|
||||||
|
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND c.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND s.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||||
|
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||||
|
page.getByRole('button', { name: /se connecter/i }).click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToHomework(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
|
||||||
|
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('WYSIWYG Editor & Backward Compatibility (Story 5.9/5.11)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
seedTeacherAssignments();
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
try {
|
||||||
|
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||||
|
} catch { /* Table may not exist */ }
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('WYSIWYG Editor', () => {
|
||||||
|
test('create form shows rich text editor with toolbar', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const editor = page.locator('.rich-text-editor');
|
||||||
|
await expect(editor).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('.toolbar')).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can create homework with rich text description', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill('Devoir texte riche');
|
||||||
|
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||||
|
await editorContent.click();
|
||||||
|
await page.keyboard.type('Consignes importantes');
|
||||||
|
|
||||||
|
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bold formatting works in editor', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill('Devoir gras test');
|
||||||
|
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||||
|
await editorContent.click();
|
||||||
|
await page.keyboard.type('Normal ');
|
||||||
|
|
||||||
|
await page.keyboard.press('Control+b');
|
||||||
|
await page.keyboard.type('en gras');
|
||||||
|
|
||||||
|
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const description = page.locator('.homework-description');
|
||||||
|
await expect(description.locator('strong')).toContainText('en gras');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('italic formatting works in editor', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill('Devoir italique test');
|
||||||
|
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||||
|
await editorContent.click();
|
||||||
|
await page.keyboard.type('Normal ');
|
||||||
|
|
||||||
|
await page.keyboard.press('Control+i');
|
||||||
|
await page.keyboard.type('en italique');
|
||||||
|
|
||||||
|
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Devoir italique test')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const description = page.locator('.homework-description');
|
||||||
|
await expect(description.locator('em')).toContainText('en italique');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bullet list formatting works in editor', async ({ page }) => {
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill('Devoir liste test');
|
||||||
|
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||||
|
await editorContent.click();
|
||||||
|
|
||||||
|
await editorContent.press('Control+Shift+8');
|
||||||
|
await page.keyboard.type('Premier élément');
|
||||||
|
|
||||||
|
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Devoir liste test')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const description = page.locator('.homework-description');
|
||||||
|
await expect(description.locator('ul')).toBeVisible();
|
||||||
|
await expect(description.locator('li')).toContainText('Premier élément');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Backward Compatibility', () => {
|
||||||
|
test('existing plain text homework displays correctly', async ({ page }) => {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||||
|
`FROM users u, school_classes c, subjects s ` +
|
||||||
|
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||||
|
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||||
|
`LIMIT 1`
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||||
|
`FROM users u, school_classes c, subjects s ` +
|
||||||
|
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||||
|
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||||
|
`LIMIT 1`
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
|
||||||
|
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
|
||||||
|
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const editorContent = page.locator('.modal .rich-text-content');
|
||||||
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
|||||||
password: string
|
password: string
|
||||||
) {
|
) {
|
||||||
await page.goto(`${ALPHA_URL}/login`);
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
await page.locator('#email').fill(email);
|
await page.locator('#email').fill(email);
|
||||||
await page.locator('#password').fill(password);
|
await page.locator('#password').fill(password);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
167
frontend/e2e/role-assignment.spec.ts
Normal file
167
frontend/e2e/role-assignment.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = 'e2e-roles-admin@example.com';
|
||||||
|
const ADMIN_PASSWORD = 'RolesAdmin123';
|
||||||
|
const TARGET_EMAIL = `e2e-roles-target-${Date.now()}@example.com`;
|
||||||
|
const TARGET_PASSWORD = 'RolesTarget123';
|
||||||
|
|
||||||
|
test.describe('Multi-Role Assignment (FR5) [P2]', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create target user with single role (PROF)
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||||
|
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||||
|
page.getByRole('button', { name: /se connecter/i }).click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRolesModalForTarget(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||||
|
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Search for the target user (paginated list may not show them on page 1)
|
||||||
|
await page.getByRole('searchbox').fill(TARGET_EMAIL);
|
||||||
|
await page.waitForTimeout(500); // debounce
|
||||||
|
|
||||||
|
// Find the target user row and click "Rôles" button
|
||||||
|
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
|
||||||
|
await expect(targetRow).toBeVisible({ timeout: 10000 });
|
||||||
|
await targetRow.getByRole('button', { name: 'Rôles' }).click();
|
||||||
|
|
||||||
|
// Modal should appear
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.locator('#roles-modal-title')).toHaveText('Modifier les rôles');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('[P2] admin can open role modal showing current roles', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Target user email should be displayed in modal
|
||||||
|
await expect(page.locator('.roles-modal-user')).toContainText(TARGET_EMAIL);
|
||||||
|
|
||||||
|
// ROLE_PROF should be checked (current role)
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
// Other roles should be unchecked
|
||||||
|
const adminCheckbox = page.locator('.role-checkbox-label', { hasText: 'Directeur' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(adminCheckbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] admin can assign multiple roles to a user', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Add Vie Scolaire role in addition to PROF
|
||||||
|
const vieScolaireLabel = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' });
|
||||||
|
await vieScolaireLabel.locator('input[type="checkbox"]').check();
|
||||||
|
|
||||||
|
// Both should now be checked
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
await expect(vieScolaireLabel.locator('input[type="checkbox"]')).toBeChecked();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const saveResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||||
|
const saveResponse = await saveResponsePromise;
|
||||||
|
expect(saveResponse.status()).toBeLessThan(400);
|
||||||
|
|
||||||
|
// Success message should appear
|
||||||
|
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] assigned roles persist after page reload', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Both PROF and VIE_SCOLAIRE should still be checked after reload
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||||
|
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
await expect(vieScolaireCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] admin can remove a role while keeping at least one', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Uncheck Vie Scolaire (added in previous test)
|
||||||
|
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||||
|
await vieScolaireCheckbox.uncheck();
|
||||||
|
|
||||||
|
// PROF should still be checked
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
await expect(vieScolaireCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const saveResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||||
|
await saveResponsePromise;
|
||||||
|
|
||||||
|
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] last role checkbox is disabled to prevent removal', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
// Only PROF should be checked now (after previous test removed VIE_SCOLAIRE)
|
||||||
|
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||||
|
await expect(profCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
// Last role checkbox should be disabled
|
||||||
|
await expect(profCheckbox).toBeDisabled();
|
||||||
|
|
||||||
|
// "(dernier rôle)" hint should be visible
|
||||||
|
await expect(
|
||||||
|
page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('.role-checkbox-hint')
|
||||||
|
).toContainText('dernier rôle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] role modal can be closed with Escape', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await openRolesModalForTarget(page);
|
||||||
|
|
||||||
|
await page.getByRole('dialog').press('Escape');
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -264,14 +264,15 @@ test.describe('Sessions Management', () => {
|
|||||||
|
|
||||||
await login(page, email);
|
await login(page, email);
|
||||||
await page.goto(getTenantUrl('/settings'));
|
await page.goto(getTenantUrl('/settings'));
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Click logout button
|
// Click logout button
|
||||||
const logoutButton = page.getByRole('button', { name: /d[eé]connexion/i });
|
const logoutButton = page.getByRole('button', { name: /d[eé]connexion/i });
|
||||||
await expect(logoutButton).toBeVisible();
|
await expect(logoutButton).toBeVisible();
|
||||||
await logoutButton.click();
|
await Promise.all([
|
||||||
|
page.waitForURL(/login/, { timeout: 30000 }),
|
||||||
// Wait for redirect to login
|
logoutButton.click()
|
||||||
await expect(page).toHaveURL(/login/, { timeout: 10000 });
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logout clears authentication', async ({ page, browserName }, testInfo) => {
|
test('logout clears authentication', async ({ page, browserName }, testInfo) => {
|
||||||
@@ -288,8 +289,8 @@ test.describe('Sessions Management', () => {
|
|||||||
await expect(logoutButton).toBeVisible();
|
await expect(logoutButton).toBeVisible();
|
||||||
await logoutButton.click();
|
await logoutButton.click();
|
||||||
|
|
||||||
// Wait for redirect to login
|
// Wait for redirect to login (Firefox can be slow to redirect)
|
||||||
await expect(page).toHaveURL(/login/, { timeout: 10000 });
|
await expect(page).toHaveURL(/login/, { timeout: 20000 });
|
||||||
|
|
||||||
// Try to access protected page
|
// Try to access protected page
|
||||||
await page.goto(getTenantUrl('/settings/sessions'));
|
await page.goto(getTenantUrl('/settings/sessions'));
|
||||||
|
|||||||
@@ -153,10 +153,11 @@ test.describe('Settings Page [P1]', () => {
|
|||||||
await login(page, email);
|
await login(page, email);
|
||||||
|
|
||||||
await page.goto(getTenantUrl('/settings'));
|
await page.goto(getTenantUrl('/settings'));
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: /tableau de bord/i })
|
page.getByRole('link', { name: /tableau de bord/i })
|
||||||
).toBeVisible();
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[P1] settings layout shows Parametres navigation link as active', async ({ page }, testInfo) => {
|
test('[P1] settings layout shows Parametres navigation link as active', async ({ page }, testInfo) => {
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ test.describe('Student Creation & Management (Story 3.0)', () => {
|
|||||||
// Should find the student (use .first() because AC3 duplicate test creates a second one)
|
// Should find the student (use .first() because AC3 duplicate test creates a second one)
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first()
|
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first()
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 20000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rows are clickable and navigate to student detail', async ({ page }) => {
|
test('rows are clickable and navigate to student detail', async ({ page }) => {
|
||||||
|
|||||||
@@ -481,7 +481,7 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
|
|||||||
await navigateToSeededDay(page);
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
||||||
await expect(firstSlot).toBeVisible({ timeout: 15000 });
|
await expect(firstSlot).toBeVisible({ timeout: 20000 });
|
||||||
await firstSlot.click();
|
await firstSlot.click();
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole('dialog');
|
||||||
|
|||||||
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const SA_PASSWORD = 'SuperAdmin123';
|
||||||
|
const UNIQUE_SUFFIX = Date.now();
|
||||||
|
|
||||||
|
function getSuperAdminEmail(browserName: string): string {
|
||||||
|
return `e2e-prov-sa-${browserName}@test.com`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-empty-pattern
|
||||||
|
test.beforeAll(async ({}, testInfo) => {
|
||||||
|
const browserName = testInfo.project.name;
|
||||||
|
const saEmail = getSuperAdminEmail(browserName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-super-admin --email=${saEmail} --password=${SA_PASSWORD} 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${browserName}] Failed to create super admin:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAsSuperAdmin(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
email: string
|
||||||
|
) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('#email').fill(email);
|
||||||
|
await page.locator('#password').fill(SA_PASSWORD);
|
||||||
|
|
||||||
|
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/super-admin/dashboard', { timeout: 30000 }),
|
||||||
|
submitButton.click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToEstablishments(page: import('@playwright/test').Page) {
|
||||||
|
const link = page.getByRole('link', { name: /établissements/i });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||||
|
link.click()
|
||||||
|
]);
|
||||||
|
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToCreateForm(page: import('@playwright/test').Page) {
|
||||||
|
const newLink = page.getByRole('link', { name: /nouvel établissement/i });
|
||||||
|
await expect(newLink).toBeVisible({ timeout: 10000 });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/super-admin/establishments/new', { timeout: 10000 }),
|
||||||
|
newLink.click()
|
||||||
|
]);
|
||||||
|
await expect(page.locator('#name')).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Establishment Provisioning (Story 2-17) [P1]', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Subdomain Auto-generation', () => {
|
||||||
|
test('[P1] typing name auto-generates subdomain', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
await page.locator('#name').fill('École Saint-Exupéry');
|
||||||
|
|
||||||
|
// Subdomain should be auto-generated: accents removed, spaces→hyphens, lowercase
|
||||||
|
await expect(page.locator('#subdomain')).toHaveValue('ecole-saint-exupery');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] subdomain suffix .classeo.fr is displayed', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
await expect(page.locator('.subdomain-suffix')).toHaveText('.classeo.fr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Create Establishment Flow', () => {
|
||||||
|
const establishmentName = `E2E Test ${UNIQUE_SUFFIX}`;
|
||||||
|
const adminEmailForEstab = `admin-prov-${UNIQUE_SUFFIX}@test.com`;
|
||||||
|
|
||||||
|
test('[P1] submitting form creates establishment and redirects to list', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
// Fill in the form
|
||||||
|
await page.locator('#name').fill(establishmentName);
|
||||||
|
await page.locator('#adminEmail').fill(adminEmailForEstab);
|
||||||
|
|
||||||
|
// Subdomain should be auto-generated
|
||||||
|
const subdomain = await page.locator('#subdomain').inputValue();
|
||||||
|
expect(subdomain.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
|
||||||
|
const apiResponsePromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/super-admin/establishments') && resp.request().method() === 'POST'
|
||||||
|
);
|
||||||
|
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Verify API returns establishment in provisioning status
|
||||||
|
const apiResponse = await apiResponsePromise;
|
||||||
|
expect(apiResponse.status()).toBeLessThan(400);
|
||||||
|
const body = await apiResponse.json();
|
||||||
|
expect(body.status).toBe('provisioning');
|
||||||
|
|
||||||
|
// Should redirect back to establishments list
|
||||||
|
await page.waitForURL('**/super-admin/establishments', { timeout: 15000 });
|
||||||
|
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P1] created establishment appears in the list', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
|
||||||
|
// The establishment created in previous test should be visible
|
||||||
|
await expect(page.locator('table')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('td', { hasText: establishmentName })).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P1] created establishment has a visible status badge', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
|
||||||
|
// Find the row for our establishment
|
||||||
|
const row = page.locator('tr', { has: page.locator(`text=${establishmentName}`) });
|
||||||
|
await expect(row).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Status badge should be visible (provisioning status already verified via API response in creation test)
|
||||||
|
const badge = row.locator('.badge');
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).not.toHaveText('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Form Validation', () => {
|
||||||
|
test('[P2] submit button disabled with empty fields', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] submit button enabled when all fields filled', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
await page.locator('#name').fill('Test School');
|
||||||
|
await page.locator('#adminEmail').fill('admin@test.com');
|
||||||
|
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[P2] cancel button returns to establishments list', async ({ page }, testInfo) => {
|
||||||
|
const email = getSuperAdminEmail(testInfo.project.name);
|
||||||
|
await loginAsSuperAdmin(page, email);
|
||||||
|
await navigateToEstablishments(page);
|
||||||
|
await navigateToCreateForm(page);
|
||||||
|
|
||||||
|
const cancelLink = page.getByRole('link', { name: /annuler/i });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||||
|
cancelLink.click()
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,7 @@ function resolveDeterministicIds(): { schoolId: string; academicYearId: string }
|
|||||||
|
|
||||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||||
await page.goto(`${ALPHA_URL}/login`);
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ test.describe('Expired/Invalid Token Scenarios [P0]', () => {
|
|||||||
await page.goto(`/activate/${token}`);
|
await page.goto(`/activate/${token}`);
|
||||||
|
|
||||||
const form = page.locator('form');
|
const form = page.locator('form');
|
||||||
await expect(form).toBeVisible({ timeout: 5000 });
|
await expect(form).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// Fill valid password (must include special char for 5/5 requirements)
|
// Fill valid password (must include special char for 5/5 requirements)
|
||||||
await page.locator('#password').fill('SecurePass123!');
|
await page.locator('#password').fill('SecurePass123!');
|
||||||
|
|||||||
@@ -0,0 +1,563 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export interface BlockedDate {
|
||||||
|
date: string;
|
||||||
|
reason: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = '',
|
||||||
|
onSelect,
|
||||||
|
min = '',
|
||||||
|
blockedDates = [],
|
||||||
|
disabled = false,
|
||||||
|
id = ''
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
onSelect: (date: string) => void;
|
||||||
|
min?: string;
|
||||||
|
blockedDates?: BlockedDate[];
|
||||||
|
disabled?: boolean;
|
||||||
|
id?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const DAYS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'] as const;
|
||||||
|
|
||||||
|
const MONTHS = [
|
||||||
|
'Janvier',
|
||||||
|
'Février',
|
||||||
|
'Mars',
|
||||||
|
'Avril',
|
||||||
|
'Mai',
|
||||||
|
'Juin',
|
||||||
|
'Juillet',
|
||||||
|
'Août',
|
||||||
|
'Septembre',
|
||||||
|
'Octobre',
|
||||||
|
'Novembre',
|
||||||
|
'Décembre'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
weekend: '#9ca3af',
|
||||||
|
holiday: '#ef4444',
|
||||||
|
vacation: '#3b82f6',
|
||||||
|
pedagogical_day: '#f59e0b',
|
||||||
|
rule_hard: '#dc2626',
|
||||||
|
rule_soft: '#f97316'
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
weekend: 'Weekend',
|
||||||
|
holiday: 'Jour férié',
|
||||||
|
vacation: 'Vacances',
|
||||||
|
pedagogical_day: 'J. pédagogique',
|
||||||
|
rule_hard: 'Règle (bloquant)',
|
||||||
|
rule_soft: 'Règle (avertissement)'
|
||||||
|
};
|
||||||
|
|
||||||
|
let isOpen = $state(false);
|
||||||
|
|
||||||
|
let month = $state(new Date().getMonth());
|
||||||
|
let year = $state(new Date().getFullYear());
|
||||||
|
|
||||||
|
// Sync month/year when value changes externally
|
||||||
|
$effect(() => {
|
||||||
|
if (value) {
|
||||||
|
const d = new Date(value + 'T00:00:00');
|
||||||
|
month = d.getMonth();
|
||||||
|
year = d.getFullYear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let monthLabel = $derived(`${MONTHS[month]} ${year}`);
|
||||||
|
let daysInMonth = $derived(new Date(year, month + 1, 0).getDate());
|
||||||
|
|
||||||
|
let firstDayOffset = $derived.by(() => {
|
||||||
|
const jsDay = new Date(year, month, 1).getDay();
|
||||||
|
return jsDay === 0 ? 6 : jsDay - 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a Set for O(1) lookup of blocked dates
|
||||||
|
let blockedDateMap = $derived.by(() => {
|
||||||
|
const map = new Map<string, BlockedDate>();
|
||||||
|
for (const bd of blockedDates) {
|
||||||
|
map.set(bd.date, bd);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DayCell {
|
||||||
|
day: number;
|
||||||
|
dateStr: string;
|
||||||
|
isToday: boolean;
|
||||||
|
isWeekend: boolean;
|
||||||
|
isBlocked: boolean;
|
||||||
|
isWarning: boolean;
|
||||||
|
blockedReason: string;
|
||||||
|
blockedType: string;
|
||||||
|
isBeforeMin: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let calendarGrid = $derived.by(() => {
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
const cells: (DayCell | null)[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < firstDayOffset; i++) {
|
||||||
|
cells.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
const dayOfWeek = (firstDayOffset + d - 1) % 7;
|
||||||
|
const isWeekend = dayOfWeek >= 5;
|
||||||
|
|
||||||
|
const blocked = blockedDateMap.get(dateStr);
|
||||||
|
const isSoftRule = blocked?.type === 'rule_soft';
|
||||||
|
const isBlocked = isWeekend || (!!blocked && !isSoftRule);
|
||||||
|
const isBeforeMin = min !== '' && dateStr < min;
|
||||||
|
|
||||||
|
cells.push({
|
||||||
|
day: d,
|
||||||
|
dateStr,
|
||||||
|
isToday: dateStr === todayStr,
|
||||||
|
isWeekend,
|
||||||
|
isBlocked,
|
||||||
|
isWarning: isSoftRule,
|
||||||
|
blockedReason: blocked?.reason ?? (isWeekend ? 'Weekend' : ''),
|
||||||
|
blockedType: blocked?.type ?? (isWeekend ? 'weekend' : ''),
|
||||||
|
isBeforeMin,
|
||||||
|
isSelected: dateStr === value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine which blocked types are visible this month for the legend
|
||||||
|
let visibleBlockedTypes = $derived.by(() => {
|
||||||
|
const types = new Set<string>();
|
||||||
|
for (const cell of calendarGrid) {
|
||||||
|
if ((cell?.isBlocked || cell?.isWarning) && cell.blockedType) {
|
||||||
|
types.add(cell.blockedType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...types];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation bounds: don't go before current month or beyond 3 months ahead
|
||||||
|
let canGoPrev = $derived.by(() => {
|
||||||
|
const now = new Date();
|
||||||
|
return year > now.getFullYear() || (year === now.getFullYear() && month > now.getMonth());
|
||||||
|
});
|
||||||
|
|
||||||
|
let canGoNext = $derived.by(() => {
|
||||||
|
const limit = new Date();
|
||||||
|
limit.setMonth(limit.getMonth() + 3);
|
||||||
|
return year < limit.getFullYear() || (year === limit.getFullYear() && month < limit.getMonth());
|
||||||
|
});
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (!canGoPrev) return;
|
||||||
|
if (month === 0) {
|
||||||
|
month = 11;
|
||||||
|
year--;
|
||||||
|
} else {
|
||||||
|
month--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
if (!canGoNext) return;
|
||||||
|
if (month === 11) {
|
||||||
|
month = 0;
|
||||||
|
year++;
|
||||||
|
} else {
|
||||||
|
month++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDate(dateStr: string) {
|
||||||
|
onSelect(dateStr);
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString('fr-FR', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.calendar-date-picker')) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('click', handleClickOutside, true);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="calendar-date-picker" class:disabled onkeydown={handleKeydown}>
|
||||||
|
{#if id}
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
{id}
|
||||||
|
value={value}
|
||||||
|
{min}
|
||||||
|
onchange={(e) => onSelect((e.target as HTMLInputElement).value)}
|
||||||
|
class="sr-only-input"
|
||||||
|
tabindex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="picker-trigger"
|
||||||
|
onclick={() => {
|
||||||
|
if (!disabled) isOpen = !isOpen;
|
||||||
|
}}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
{#if value}
|
||||||
|
<span class="picker-value">{formatDisplayDate(value)}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="picker-placeholder">Choisir une date</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="calendar-dropdown" role="dialog" aria-label="Calendrier">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<button type="button" class="nav-btn" onclick={prevMonth} disabled={!canGoPrev} aria-label="Mois précédent">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="month-label">{monthLabel}</span>
|
||||||
|
<button type="button" class="nav-btn" onclick={nextMonth} disabled={!canGoNext} aria-label="Mois suivant">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-grid" role="grid" aria-label="Jours du mois">
|
||||||
|
<div class="calendar-row day-names" role="row">
|
||||||
|
{#each DAYS as day}
|
||||||
|
<div class="day-name" role="columnheader">{day}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each { length: Math.ceil(calendarGrid.length / 7) } as _, weekIndex}
|
||||||
|
<div class="calendar-row" role="row">
|
||||||
|
{#each calendarGrid.slice(weekIndex * 7, weekIndex * 7 + 7) as cell}
|
||||||
|
{#if cell === null}
|
||||||
|
<div class="day-cell empty" role="gridcell"></div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="day-cell"
|
||||||
|
class:today={cell.isToday}
|
||||||
|
class:selected={cell.isSelected}
|
||||||
|
class:weekend={cell.isWeekend}
|
||||||
|
class:blocked={cell.isBlocked}
|
||||||
|
class:warning={cell.isWarning}
|
||||||
|
class:before-min={cell.isBeforeMin}
|
||||||
|
disabled={cell.isBlocked || cell.isBeforeMin}
|
||||||
|
onclick={() => selectDate(cell.dateStr)}
|
||||||
|
title={cell.isBlocked || cell.isWarning
|
||||||
|
? cell.blockedReason
|
||||||
|
: cell.isBeforeMin
|
||||||
|
? 'Date antérieure au minimum autorisé'
|
||||||
|
: ''}
|
||||||
|
role="gridcell"
|
||||||
|
aria-selected={cell.isSelected}
|
||||||
|
aria-disabled={cell.isBlocked || cell.isBeforeMin}
|
||||||
|
>
|
||||||
|
{cell.day}
|
||||||
|
{#if (cell.isBlocked || cell.isWarning) && !cell.isWeekend}
|
||||||
|
<span
|
||||||
|
class="blocked-dot"
|
||||||
|
style="background-color: {TYPE_COLORS[cell.blockedType] || '#9ca3af'}"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if visibleBlockedTypes.length > 0}
|
||||||
|
<div class="calendar-legend">
|
||||||
|
{#each visibleBlockedTypes as type}
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-dot" style="background-color: {TYPE_COLORS[type] || '#9ca3af'}"></span>
|
||||||
|
<span class="legend-label">{TYPE_LABELS[type] || type}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sr-only-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-date-picker {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-date-picker.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-trigger:hover:not(:disabled) {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-trigger:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 50;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-names {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-transform: uppercase;
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.empty {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell:not(.empty):not(:disabled):hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.today {
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset 0 0 0 1px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.selected {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.selected:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.weekend {
|
||||||
|
color: #d1d5db;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.blocked:not(.weekend) {
|
||||||
|
color: #d1d5db;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.warning {
|
||||||
|
color: #9a3412;
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.before-min {
|
||||||
|
color: #e5e7eb;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.125rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 0.25rem;
|
||||||
|
height: 0.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 0.375rem;
|
||||||
|
height: 0.375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
let files = $state<UploadedFile[]>(existingFiles);
|
let files = $state<UploadedFile[]>(existingFiles);
|
||||||
let pendingFiles = $state<{ name: string; size: number }[]>([]);
|
let pendingFiles = $state<{ name: string; size: number }[]>([]);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
let isDragging = $state(false);
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -52,6 +53,15 @@
|
|||||||
return '📎';
|
return '📎';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatFileType(mimeType: string): string {
|
||||||
|
if (mimeType === 'application/pdf') return 'PDF';
|
||||||
|
if (mimeType === 'image/jpeg') return 'JPEG';
|
||||||
|
if (mimeType === 'image/png') return 'PNG';
|
||||||
|
if (mimeType.includes('wordprocessingml')) return 'DOCX';
|
||||||
|
const parts = mimeType.split('/');
|
||||||
|
return parts[1]?.toUpperCase() ?? mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
function validateFile(file: File): string | null {
|
function validateFile(file: File): string | null {
|
||||||
if (!acceptedTypes.includes(file.type)) {
|
if (!acceptedTypes.includes(file.type)) {
|
||||||
return `Type de fichier non accepté : ${file.type}.`;
|
return `Type de fichier non accepté : ${file.type}.`;
|
||||||
@@ -62,14 +72,10 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileSelect(event: Event) {
|
async function processFiles(fileList: globalThis.FileList) {
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const selectedFiles = input.files;
|
|
||||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
|
||||||
|
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
for (const file of selectedFiles) {
|
for (const file of fileList) {
|
||||||
const validationError = validateFile(file);
|
const validationError = validateFile(file);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
error = validationError;
|
error = validationError;
|
||||||
@@ -87,10 +93,37 @@
|
|||||||
pendingFiles = pendingFiles.filter((p) => p.name !== file.name);
|
pendingFiles = pendingFiles.filter((p) => p.name !== file.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const selectedFiles = input.files;
|
||||||
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
await processFiles(selectedFiles);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(event: DragEvent) {
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
const related = event.relatedTarget as globalThis.Node | null;
|
||||||
|
if (related && target.contains(related)) return;
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
const droppedFiles = event.dataTransfer?.files;
|
||||||
|
if (!droppedFiles || droppedFiles.length === 0) return;
|
||||||
|
await processFiles(droppedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(fileId: string) {
|
async function handleDelete(fileId: string) {
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
@@ -114,6 +147,7 @@
|
|||||||
<li class="file-item">
|
<li class="file-item">
|
||||||
<span class="file-icon">{getFileIcon(file.mimeType)}</span>
|
<span class="file-icon">{getFileIcon(file.mimeType)}</span>
|
||||||
<span class="file-name">{file.filename}</span>
|
<span class="file-name">{file.filename}</span>
|
||||||
|
<span class="file-type">{formatFileType(file.mimeType)}</span>
|
||||||
<span class="file-size">{formatFileSize(file.fileSize)}</span>
|
<span class="file-size">{formatFileSize(file.fileSize)}</span>
|
||||||
{#if !disabled && showDelete}
|
{#if !disabled && showDelete}
|
||||||
<button
|
<button
|
||||||
@@ -140,22 +174,38 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !disabled}
|
{#if !disabled}
|
||||||
<button type="button" class="upload-btn" onclick={() => fileInput.click()}>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
<div
|
||||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
class="drop-zone"
|
||||||
|
class:drop-zone-active={isDragging}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
onclick={() => fileInput.click()}
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
Ajouter un fichier
|
<p class="drop-zone-text">
|
||||||
|
Glissez-déposez vos fichiers ici ou
|
||||||
|
<button type="button" class="drop-zone-browse" onclick={(e) => { e.stopPropagation(); fileInput.click(); }}>
|
||||||
|
parcourir
|
||||||
</button>
|
</button>
|
||||||
|
</p>
|
||||||
|
<p class="upload-hint">{hint}</p>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept={acceptAttr}
|
accept={acceptAttr}
|
||||||
onchange={handleFileSelect}
|
onchange={handleFileSelect}
|
||||||
|
multiple
|
||||||
class="file-input-hidden"
|
class="file-input-hidden"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
/>
|
/>
|
||||||
<p class="upload-hint">{hint}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -216,6 +266,16 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-type {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.file-size {
|
.file-size {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -249,25 +309,51 @@
|
|||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-btn {
|
.drop-zone {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 1.5rem;
|
||||||
border: 1px dashed #d1d5db;
|
border: 2px dashed #d1d5db;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.5rem;
|
||||||
background: white;
|
background: #fafafa;
|
||||||
color: #374151;
|
color: #6b7280;
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, background-color 0.15s;
|
transition: border-color 0.15s, background-color 0.15s;
|
||||||
align-self: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-btn:hover {
|
.drop-zone:hover {
|
||||||
border-color: #3b82f6;
|
border-color: #93c5fd;
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-active {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-browse {
|
||||||
|
display: inline;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-browse:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-input-hidden {
|
.file-input-hidden {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||||
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
|
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
|
||||||
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
|
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
|
||||||
|
import CalendarDatePicker from '$lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte';
|
||||||
|
import type { BlockedDate } from '$lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte';
|
||||||
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
|
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
|
||||||
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
|
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
|
||||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||||
@@ -86,6 +88,14 @@
|
|||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let newPendingFiles = $state<File[]>([]);
|
let newPendingFiles = $state<File[]>([]);
|
||||||
|
|
||||||
|
// Blocked dates for calendar
|
||||||
|
let calendarBlockedDates = $state<BlockedDate[]>([]);
|
||||||
|
|
||||||
|
// Edit calendar: rules don't apply (homework already assigned to students)
|
||||||
|
let editCalendarBlockedDates = $derived(
|
||||||
|
calendarBlockedDates.filter((d) => !d.type.startsWith('rule_'))
|
||||||
|
);
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
let editAttachments = $state<HomeworkAttachmentFile[]>([]);
|
let editAttachments = $state<HomeworkAttachmentFile[]>([]);
|
||||||
|
|
||||||
@@ -96,6 +106,7 @@
|
|||||||
let editDescription = $state('');
|
let editDescription = $state('');
|
||||||
let editDueDate = $state('');
|
let editDueDate = $state('');
|
||||||
let isUpdating = $state(false);
|
let isUpdating = $state(false);
|
||||||
|
let editError = $state<string | null>(null);
|
||||||
|
|
||||||
// Delete modal
|
// Delete modal
|
||||||
let showDeleteModal = $state(false);
|
let showDeleteModal = $state(false);
|
||||||
@@ -158,7 +169,10 @@
|
|||||||
|
|
||||||
// Load on mount
|
// Load on mount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
untrack(() => loadAll());
|
untrack(() => {
|
||||||
|
loadAll();
|
||||||
|
loadBlockedDates();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function extractCollection<T>(data: Record<string, unknown>): T[] {
|
function extractCollection<T>(data: Record<string, unknown>): T[] {
|
||||||
@@ -168,6 +182,28 @@
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadBlockedDates() {
|
||||||
|
try {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const today = new Date();
|
||||||
|
const startDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
// Load 3 months ahead (stays within current academic year boundary)
|
||||||
|
const endDate = new Date(today.getFullYear(), today.getMonth() + 4, 0);
|
||||||
|
const endDateStr = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const res = await authenticatedFetch(
|
||||||
|
`${apiUrl}/schedule/blocked-dates?startDate=${startDate}&endDate=${endDateStr}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
calendarBlockedDates = extractCollection<BlockedDate>(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail — calendar will work without blocked dates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAssignments() {
|
async function loadAssignments() {
|
||||||
const userId = await getAuthenticatedUserId();
|
const userId = await getAuthenticatedUserId();
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
@@ -602,6 +638,7 @@
|
|||||||
editDescription = hw.description ?? '';
|
editDescription = hw.description ?? '';
|
||||||
editDueDate = hw.dueDate;
|
editDueDate = hw.dueDate;
|
||||||
editAttachments = [];
|
editAttachments = [];
|
||||||
|
editError = null;
|
||||||
showEditModal = true;
|
showEditModal = true;
|
||||||
|
|
||||||
// Charger les pièces jointes existantes en arrière-plan
|
// Charger les pièces jointes existantes en arrière-plan
|
||||||
@@ -619,7 +656,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
error = null;
|
editError = null;
|
||||||
const apiUrl = getApiBaseUrl();
|
const apiUrl = getApiBaseUrl();
|
||||||
const response = await authenticatedFetch(`${apiUrl}/homework/${editHomework.id}`, {
|
const response = await authenticatedFetch(`${apiUrl}/homework/${editHomework.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -644,7 +681,7 @@
|
|||||||
closeEditModal();
|
closeEditModal();
|
||||||
await loadHomeworks();
|
await loadHomeworks();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Erreur lors de la modification';
|
editError = e instanceof Error ? e.message : 'Erreur lors de la modification';
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating = false;
|
isUpdating = false;
|
||||||
}
|
}
|
||||||
@@ -986,14 +1023,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="hw-due-date">Date d'échéance *</label>
|
<label>Date d'échéance *</label>
|
||||||
<input
|
<CalendarDatePicker
|
||||||
type="date"
|
|
||||||
id="hw-due-date"
|
id="hw-due-date"
|
||||||
value={newDueDate}
|
value={newDueDate}
|
||||||
oninput={(e) => handleDueDateChange((e.target as HTMLInputElement).value)}
|
onSelect={(date) => handleDueDateChange(date)}
|
||||||
required
|
|
||||||
min={ruleConformMinDate || minDueDate}
|
min={ruleConformMinDate || minDueDate}
|
||||||
|
blockedDates={calendarBlockedDates}
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
{#if dueDateError}
|
{#if dueDateError}
|
||||||
<small class="form-hint form-hint-error">{dueDateError}</small>
|
<small class="form-hint form-hint-error">{dueDateError}</small>
|
||||||
@@ -1001,8 +1038,6 @@
|
|||||||
<small class="form-hint form-hint-rule">
|
<small class="form-hint form-hint-rule">
|
||||||
Date minimale conforme aux règles : {new Date(ruleConformMinDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
Date minimale conforme aux règles : {new Date(ruleConformMinDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
</small>
|
</small>
|
||||||
{:else}
|
|
||||||
<small class="form-hint">La date doit être au minimum demain, hors jours fériés et vacances</small>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1073,6 +1108,14 @@
|
|||||||
handleUpdate();
|
handleUpdate();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{#if editError}
|
||||||
|
<div class="alert alert-error" style="margin-bottom: 1rem;">
|
||||||
|
<span class="alert-icon">⚠</span>
|
||||||
|
{editError}
|
||||||
|
<button class="alert-close" onclick={() => (editError = null)}>×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="form-info">
|
<div class="form-info">
|
||||||
<span class="info-label">Classe :</span>
|
<span class="info-label">Classe :</span>
|
||||||
<span>{editHomework.className ?? getClassName(editHomework.classId)}</span>
|
<span>{editHomework.className ?? getClassName(editHomework.classId)}</span>
|
||||||
@@ -1103,8 +1146,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-due-date">Date d'échéance *</label>
|
<label>Date d'échéance *</label>
|
||||||
<input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} />
|
<CalendarDatePicker
|
||||||
|
id="edit-due-date"
|
||||||
|
value={editDueDate}
|
||||||
|
onSelect={(date) => (editDueDate = date)}
|
||||||
|
min={minDueDate}
|
||||||
|
blockedDates={editCalendarBlockedDates}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if editHomework}
|
{#if editHomework}
|
||||||
|
|||||||
@@ -71,8 +71,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="subdomain-cell">{establishment.subdomain}</td>
|
<td class="subdomain-cell">{establishment.subdomain}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" class:active={establishment.status === 'active'}>
|
<span
|
||||||
{establishment.status === 'active' ? 'Actif' : 'Inactif'}
|
class="badge"
|
||||||
|
class:active={establishment.status === 'active'}
|
||||||
|
class:provisioning={establishment.status === 'provisioning'}
|
||||||
|
>
|
||||||
|
{establishment.status === 'active'
|
||||||
|
? 'Actif'
|
||||||
|
: establishment.status === 'provisioning'
|
||||||
|
? 'Provisioning…'
|
||||||
|
: 'Inactif'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="date-cell">
|
<td class="date-cell">
|
||||||
@@ -207,6 +215,11 @@
|
|||||||
color: #16a34a;
|
color: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge.provisioning {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
.actions-cell {
|
.actions-cell {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user