20 Commits
Author SHA1 Message Date
fromme 679a864906 chore: update project dependencies 2026-09-21 10:10:59 +02:00
frommeandClaude Opus 5 f080d05dd6 fix: normalize the login email case
The login address was only trimmed, so the casing somebody happened to type at
their very first login was frozen into the user row forever — createOrUpdateLocalUser()
never wrote the address back. Everything downstream re-sends the stored address
rather than the one just authenticated with, which also leaked that casing into the
OAuth2 email claim and the log identities.

Harmless in practice, since BusPro matches an address case-insensitively and so does
the utf8mb4_unicode_ci column, but it left User out of step with the newsletter
entities, which have always normalized.

Fold the case once in authenticate(), which covers the BusPro calls, the lookup and a
new account alike, and write the address back on every login so an account created
before this converges instead of staying frozen. No backfill: a row nobody logs into
again is matched case-insensitively either way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 10:03:06 +02:00
fromme 00637042e2 chore: adjust tests to changed role labels 2026-09-21 10:02:29 +02:00
fromme fbb3ebb8c1 fix: make bpn response failures diagnosable 2026-09-19 11:51:15 +02:00
fromme ec83ad598f feat: identify the authenticated account in the userinfo claims 2026-09-19 10:54:06 +02:00
fromme 2cb4871268 chore: update wording of role labels 2026-09-17 18:18:13 +02:00
fromme c1415dee41 chore: update project dependencies 2026-09-17 18:14:41 +02:00
fromme 540cd8eeb7 feat: reserve administrative roles for staff email addresses 2026-09-17 18:13:52 +02:00
fromme 725e32daab feat: gender role label correctly 2026-09-16 15:33:40 +02:00
fromme 160ebef39e fix: stop offering unbookable services in the booking edit flow 2026-09-16 15:28:07 +02:00
fromme 672fc30d7e doc: update bpn xml api reference 2026-09-16 15:25:50 +02:00
fromme ba4f66f117 chore: enable basic auth in staging environment 2026-09-16 15:25:16 +02:00
fromme 12fe4d8e15 feat: offer contact validation flow 2026-09-16 10:40:01 +02:00
fromme 5d7ffd7d58 feat: configurable recipient list for notifications of role nominations 2026-09-16 09:21:04 +02:00
fromme 6a8328a564 fix: cachetool 404 on deploy 2026-09-15 15:42:01 +02:00
fromme 019de4e705 feat: surface stuck booking edits to customer experts 2026-09-15 15:13:47 +02:00
fromme a1d1fdde14 feat: gate functionality for customer expert users 2026-09-15 15:13:47 +02:00
fromme 1d14f00bb7 feat: additional role 'customer expert' 2026-09-15 15:00:59 +02:00
fromme 042037867b feat: cli command to repair corrupted booking snapshots 2026-09-15 13:57:41 +02:00
fromme 5f6ff13ce7 chore: cleanup and optimize deployer config 2026-09-15 11:55:51 +02:00
82 changed files with 4024 additions and 1109 deletions
+2
View File
@@ -96,6 +96,8 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
APP_DEFAULT_EMAIL_FROM=[email protected] APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected] APP_DEFAULT_EMAIL_TO=[email protected]
ACCOMMODATION_INQUIRY_EMAIL=[email protected] ACCOMMODATION_INQUIRY_EMAIL=[email protected]
# Comma-separated; recipients of the role nomination notification. Empty means disable notification.
APP_ROLE_NOMINATION_EMAILS=
# Global common defaults # Global common defaults
APP_SEASON_WINTER_FROM=2026-10-01 APP_SEASON_WINTER_FROM=2026-10-01
Generated
+74 -219
View File
@@ -935,27 +935,25 @@
}, },
{ {
"name": "doctrine/lexer", "name": "doctrine/lexer",
"version": "3.0.1", "version": "3.0.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/lexer.git", "url": "https://github.com/doctrine/lexer.git",
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c",
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^8.1" "php": "^8.1"
}, },
"require-dev": { "require-dev": {
"doctrine/coding-standard": "^12", "doctrine/coding-standard": "^14",
"phpstan/phpstan": "^1.10", "phpstan/phpstan": "^2",
"phpunit/phpunit": "^10.5", "phpunit/phpunit": "^10.5.58 || ^12.5.4"
"psalm/plugin-phpunit": "^0.18.3",
"vimeo/psalm": "^5.21"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -992,7 +990,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/doctrine/lexer/issues", "issues": "https://github.com/doctrine/lexer/issues",
"source": "https://github.com/doctrine/lexer/tree/3.0.1" "source": "https://github.com/doctrine/lexer/tree/3.0.2"
}, },
"funding": [ "funding": [
{ {
@@ -1008,7 +1006,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-02-05T11:56:58+00:00" "time": "2026-06-14T20:44:06+00:00"
}, },
{ {
"name": "doctrine/migrations", "name": "doctrine/migrations",
@@ -3851,16 +3849,16 @@
}, },
{ {
"name": "phpoffice/phpspreadsheet", "name": "phpoffice/phpspreadsheet",
"version": "5.9.0", "version": "5.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3889,6 +3887,7 @@
"dealerdirect/phpcodesniffer-composer-installer": "dev-main", "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0", "dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*", "ext-intl": "*",
"ext-openssl": "*",
"friendsofphp/php-cs-fixer": "^3.2", "friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5", "mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1", "mpdf/mpdf": "^8.1.1",
@@ -3898,11 +3897,12 @@
"phpstan/phpstan-phpunit": "^1.0 || ^2.0", "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5 || ^11.0", "phpunit/phpunit": "^10.5 || ^11.0",
"squizlabs/php_codesniffer": "^3.7", "squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5" "tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
}, },
"suggest": { "suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer", "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer", "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
@@ -3954,9 +3954,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0"
}, },
"time": "2026-07-12T19:17:39+00:00" "time": "2026-09-17T03:54:50+00:00"
}, },
{ {
"name": "phpseclib/phpseclib", "name": "phpseclib/phpseclib",
@@ -4695,35 +4695,33 @@
}, },
{ {
"name": "sabberworm/php-css-parser", "name": "sabberworm/php-css-parser",
"version": "v9.4.0", "version": "v9.5.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-iconv": "*", "ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0"
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
}, },
"require-dev": { "require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0", "php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3", "phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.2", "phpstan/phpstan": "1.12.33 || 2.2.9",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", "phpstan/phpstan-phpunit": "1.4.2 || 2.0.18",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12",
"phpunit/phpunit": "8.5.52", "phpunit/phpunit": "8.5.54",
"rawr/phpunit-data-provider": "3.3.1", "rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.4.6", "rector/rector": "1.2.10 || 2.6.2",
"rector/type-perfect": "1.0.0 || 2.1.3", "rector/type-perfect": "1.0.0 || 2.1.4",
"squizlabs/php_codesniffer": "4.0.1", "squizlabs/php_codesniffer": "4.0.4"
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
}, },
"suggest": { "suggest": {
"ext-mbstring": "for parsing UTF-8 CSS" "ext-mbstring": "for parsing UTF-8 CSS"
@@ -4731,7 +4729,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "9.5.x-dev" "dev-main": "9.6.x-dev"
} }
}, },
"autoload": { "autoload": {
@@ -4769,9 +4767,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0"
}, },
"time": "2026-06-18T15:10:53+00:00" "time": "2026-09-20T15:02:00+00:00"
}, },
{ {
"name": "spatie/crypto", "name": "spatie/crypto",
@@ -11058,16 +11056,16 @@
}, },
{ {
"name": "symfony/webpack-encore-bundle", "name": "symfony/webpack-encore-bundle",
"version": "v2.4.1", "version": "2.4.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/webpack-encore-bundle.git", "url": "https://github.com/symfony/webpack-encore-bundle.git",
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5" "reference": "0cbc3485f127cd9f85e395f8177607ef00535634"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/cac8d6c722999c8add9272f9de6e8079628df4f5", "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/0cbc3485f127cd9f85e395f8177607ef00535634",
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5", "reference": "0cbc3485f127cd9f85e395f8177607ef00535634",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11110,7 +11108,7 @@
"description": "Integration of your Symfony app with Webpack Encore", "description": "Integration of your Symfony app with Webpack Encore",
"support": { "support": {
"issues": "https://github.com/symfony/webpack-encore-bundle/issues", "issues": "https://github.com/symfony/webpack-encore-bundle/issues",
"source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.4.1" "source": "https://github.com/symfony/webpack-encore-bundle/tree/2.4.2"
}, },
"funding": [ "funding": [
{ {
@@ -11130,7 +11128,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-24T07:21:58+00:00" "time": "2026-09-17T12:45:02+00:00"
}, },
{ {
"name": "symfony/yaml", "name": "symfony/yaml",
@@ -11208,161 +11206,18 @@
], ],
"time": "2026-08-30T00:47:26+00:00" "time": "2026-08-30T00:47:26+00:00"
}, },
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{ {
"name": "twig/extra-bundle", "name": "twig/extra-bundle",
"version": "v3.24.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/twig-extra-bundle.git", "url": "https://github.com/twigphp/twig-extra-bundle.git",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/aaa2993e19293a99240c4c61aa461d743b0dd569",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11411,7 +11266,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0" "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11423,20 +11278,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-02-07T08:07:38+00:00" "time": "2026-09-11T08:59:50+00:00"
}, },
{ {
"name": "twig/html-extra", "name": "twig/html-extra",
"version": "v3.28.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/html-extra.git", "url": "https://github.com/twigphp/html-extra.git",
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7" "reference": "7147611979df81edb78baf7d003860b72e335c3f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/html-extra/zipball/760893ed7bdd0a381e4e00004c6f6e26ad3881d7", "url": "https://api.github.com/repos/twigphp/html-extra/zipball/7147611979df81edb78baf7d003860b72e335c3f",
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7", "reference": "7147611979df81edb78baf7d003860b72e335c3f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11479,7 +11334,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/twigphp/html-extra/tree/v3.28.0" "source": "https://github.com/twigphp/html-extra/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11491,20 +11346,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-25T06:50:01+00:00" "time": "2026-09-06T20:35:36+00:00"
}, },
{ {
"name": "twig/intl-extra", "name": "twig/intl-extra",
"version": "v3.26.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/intl-extra.git", "url": "https://github.com/twigphp/intl-extra.git",
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9" "reference": "51c52470aca59f3a9715c88f6fe505133b88c752"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/51c52470aca59f3a9715c88f6fe505133b88c752",
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", "reference": "51c52470aca59f3a9715c88f6fe505133b88c752",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11543,7 +11398,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/twigphp/intl-extra/tree/v3.26.0" "source": "https://github.com/twigphp/intl-extra/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11555,7 +11410,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-19T20:44:48+00:00" "time": "2026-09-06T20:35:36+00:00"
}, },
{ {
"name": "twig/string-extra", "name": "twig/string-extra",
@@ -11626,16 +11481,16 @@
}, },
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.28.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/Twig.git", "url": "https://github.com/twigphp/Twig.git",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "url": "https://api.github.com/repos/twigphp/Twig/zipball/45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11690,7 +11545,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/twigphp/Twig/issues", "issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.28.0" "source": "https://github.com/twigphp/Twig/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11702,7 +11557,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-07-03T20:44:34+00:00" "time": "2026-09-18T09:10:14+00:00"
}, },
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
@@ -12295,16 +12150,16 @@
}, },
{ {
"name": "friendsofphp/php-cs-fixer", "name": "friendsofphp/php-cs-fixer",
"version": "v3.95.25", "version": "v3.95.26",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff" "reference": "11839dfaf25718e1617c522c3dde01ebf96c875f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/11839dfaf25718e1617c522c3dde01ebf96c875f",
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", "reference": "11839dfaf25718e1617c522c3dde01ebf96c875f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12338,15 +12193,15 @@
"require-dev": { "require-dev": {
"facile-it/paraunit": "^1.3.1 || ^2.11.0", "facile-it/paraunit": "^1.3.1 || ^2.11.0",
"infection/infection": "^0.32.7", "infection/infection": "^0.32.7",
"justinrainbow/json-schema": "^6.10.0", "justinrainbow/json-schema": "^6.12.0",
"keradus/cli-executor": "^2.3", "keradus/cli-executor": "^2.3",
"php-coveralls/php-coveralls": "^2.9.1", "php-coveralls/php-coveralls": "^2.9.1",
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
"phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", "phpunit/phpunit": "^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.35 || ^13.3.3",
"symfony/polyfill-php85": "^1.38", "symfony/polyfill-php85": "^1.41",
"symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", "symfony/var-dumper": "^5.4.48 || ^6.4.45 || ^7.4.18 || ^8.1.7",
"symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" "symfony/yaml": "^5.4.53 || ^6.4.45 || ^7.4.18 || ^8.1.6"
}, },
"suggest": { "suggest": {
"ext-dom": "For handling output formats in XML", "ext-dom": "For handling output formats in XML",
@@ -12387,7 +12242,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25" "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.26"
}, },
"funding": [ "funding": [
{ {
@@ -12395,7 +12250,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-09-08T11:11:37+00:00" "time": "2026-09-19T19:04:32+00:00"
}, },
{ {
"name": "myclabs/deep-copy", "name": "myclabs/deep-copy",
+4
View File
@@ -17,8 +17,12 @@ security:
roles: [ ROLE_MAILJET_WEBHOOK ] roles: [ ROLE_MAILJET_WEBHOOK ]
role_hierarchy: role_hierarchy:
# ROLE_CUSTOMER_EXPERT gates the booking-draft and log surfaces, which used to be
# ROLE_ADMIN only. Inheriting it here is what keeps those surfaces open to administrators
# after the #[IsGranted] attributes moved to the narrower role.
ROLE_ADMIN: ROLE_ADMIN:
- ROLE_GROUPS_ADMIN - ROLE_GROUPS_ADMIN
- ROLE_CUSTOMER_EXPERT
ROLE_GROUPS_ADMIN: ROLE_GROUPS_ADMIN:
- ROLE_GROUPS_MANAGER - ROLE_GROUPS_MANAGER
firewalls: firewalls:
+7
View File
@@ -15,6 +15,7 @@ parameters:
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%' accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
# MailJet list ids and their labels # MailJet list ids and their labels
mailjet_lists: mailjet_lists:
@@ -266,6 +267,7 @@ services:
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule' - '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
# Dashboard Widgets # Dashboard Widgets
App\Dashboard\Widget\StuckBookingDraftsWidgetProvider: ~
App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~ App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~
App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~ App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~
App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~ App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~
@@ -274,6 +276,7 @@ services:
App\Dashboard\DashboardWidgetRegistry: App\Dashboard\DashboardWidgetRegistry:
arguments: arguments:
$providers: $providers:
- '@App\Dashboard\Widget\StuckBookingDraftsWidgetProvider'
- '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider' - '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider'
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider' - '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider' - '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
@@ -309,6 +312,10 @@ services:
arguments: arguments:
$domains: '%employee_email_domains%' $domains: '%employee_email_domains%'
App\MessageHandler\RoleNominationHandler:
arguments:
$notificationRecipients: '%role_nomination_notification_emails%'
App\Service\DomainConfigProvider: App\Service\DomainConfigProvider:
arguments: arguments:
$domainConfig: '%domain_config%' $domainConfig: '%domain_config%'
+29 -14
View File
@@ -9,7 +9,6 @@ require 'recipe/symfony.php';
add('shared_dirs', [ add('shared_dirs', [
'config/secret', 'config/secret',
'var/bpn', 'var/bpn',
'var/log',
'var/sessions', 'var/sessions',
'var/jsonexport', 'var/jsonexport',
'var/xmlexport', 'var/xmlexport',
@@ -35,6 +34,8 @@ $rsyncOptions = [
'.jj', '.jj',
'node_modules', 'node_modules',
'.editorconfig', '.editorconfig',
'.env.local',
'.env.local.php',
'.env.dev.local', '.env.dev.local',
'.env.dev.local.php', '.env.dev.local.php',
'.env.test', '.env.test',
@@ -76,56 +77,70 @@ $rsyncOptions = [
'timeout' => 300, 'timeout' => 300,
]; ];
// Shared by all hosts.
set('bin/php', '/usr/bin/php');
set('http_user', 'myepsf');
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
set('rsync_src', __DIR__);
set('rsync', $rsyncOptions);
host('prod') host('prod')
->setHostname('dedi10193.your-server.de') ->setHostname('dedi10193.your-server.de')
->setDeployPath('/usr/www/users/myepsf/prod')
->setRemoteUser('myepsf') ->setRemoteUser('myepsf')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/usr/home/myepsf/public_html/prod')
->set('bin/php', '/usr/bin/php')
->set('http_user', 'myepsf')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
; ;
host('staging') host('staging')
->setHostname('dedi10193.your-server.de') ->setHostname('dedi10193.your-server.de')
->setDeployPath('/usr/www/users/myepsf/staging')
->setRemoteUser('myepsf') ->setRemoteUser('myepsf')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/usr/home/myepsf/public_html/staging') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
->set('bin/php', '/usr/bin/php')
->set('http_user', 'myepsf')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net')
->add('shared_files', [ ->add('shared_files', [
'public/.htaccess',
'public/.htpasswd', 'public/.htpasswd',
]) ])
; ;
task('deploy', [ task('deploy', [
'deploy:info', 'deploy:info',
'deploy:assets',
'deploy:setup', 'deploy:setup',
'deploy:lock', 'deploy:lock',
'deploy:release', 'deploy:release',
'deploy:assets',
'rsync', 'rsync',
'deploy:shared', 'deploy:shared',
'deploy:writable', 'deploy:writable',
'deploy:cache:clear', 'deploy:cache:warmup',
'database:migrate', 'database:migrate',
// Must run before the symlink flip: the cachetool probe file is only reachable through
// the release the docroot currently resolves to. It still pays off, because it drops the
// opcache entries keyed under current/public/*.
'cachetool:clear:opcache', 'cachetool:clear:opcache',
'deploy:publish', 'deploy:publish',
'deploy:stop-workers', 'deploy:stop-workers',
]); ]);
// Purely local, so it runs before anything is created on the remote. once(), so
// deploying multiple hosts at the same time builds the bundle only once.
task('deploy:assets', function () { task('deploy:assets', function () {
runLocally('ddev exec npm ci'); runLocally('ddev exec npm ci');
runLocally('ddev exec npm run build'); runLocally('ddev exec npm run build');
})->once();
// The recipe's deploy:cache:clear only does something when composer ran with
// --no-scripts, which never happens here: vendor/ is rsynced, composer never runs
// remotely. A fresh release has no var/cache to clear, so warm it up directly.
// Runs after deploy:writable so the default ACLs are already in place.
task('deploy:cache:warmup', function () {
run('{{bin/console}} cache:warmup {{console_options}}');
}); });
// Workers keep running the previous release's code until they are told to stop.
task('deploy:stop-workers', function () { task('deploy:stop-workers', function () {
run('{{bin/console}} messenger:stop-workers'); run('{{bin/console}} messenger:stop-workers');
}); });
+24 -3
View File
@@ -379,9 +379,9 @@ Responses:
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes: OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
- always: `email` - always: `sub` (string), `email`
- scope `id`: `person_id`, `address_id` - scope `id`: `person_id`, `address_id`
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/user-roles.md`) - scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
- scope `profile`: `profile` object: - scope `profile`: `profile` object:
```json ```json
@@ -394,7 +394,28 @@ OIDC-style claims for the authenticated user. The response contains only the cla
} }
``` ```
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity. ##### Identifying the account: match on `sub`
A BusPro address record holds several email addresses (private and business, say), and BusPro
accepts **any** of them as a login. Each one is a **separate MyE&P account with its own roles**
that is intended, not a duplicate: administrative roles are reserved for the company address, so
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
`@ep-reisen.de` address is an administrator.
All of those accounts report the **same** `person_id` and `address_id`, and the same
`profile.communication.email` — that value is the first contact address on the BusPro record and
is **not necessarily** the one signed in with. So:
- **`sub`** — opaque, stable, unique per MyE&P account. The only claim that identifies an account.
Match your local user on it and store it.
- **`email`** — the address this session actually authenticated with. Distinct per account, but
treat it as a display and contact value; it is not the account key.
- **`person_id` / `address_id`** — the BusPro person behind the account. Shared between that
person's accounts, so they identify a *human*, never an account. Do not match on them.
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
exports the result as `ROLE_EMPLOYEE` in the `roles` claim; re-deriving it from `email` or from
`profile.communication.email` will disagree with MyE&P in both directions.
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials. `400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
+4
View File
@@ -577,6 +577,10 @@ Known role mapping from the XML parser:
- admin users also receive default hotel code `SSL` - admin users also receive default hotel code `SSL`
- no matched role means `ROLE_CUSTOMER` - no matched role means `ROLE_CUSTOMER`
These are the CRM's claims. The portal filters them before storing anything (staff-only
roles, approval markers), so a proxy must return the claims unfiltered — see
`docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`.
Candidate SQL for selected customer selections: Candidate SQL for selected customer selections:
```sql ```sql
+13 -1
View File
@@ -166,6 +166,18 @@ genuinely missing, which is a useful way to audit an address: the placeholder ac
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
`telefonprivat` and no `nationalitaet`. `telefonprivat` and no `nationalitaet`.
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
real step-3 `Anfrage`). Once a room is present, every `teilnehmer` needs a `Mail-Adresse`, children
included: without `kommunikation` the answer is `650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`.
`telefonmobil` and an empty `anschrift` pass for participants. An email identical to the Anmelder's is
accepted. The field list above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on
the placeholder records and is stricter than what BusPro applies to ordinary participants.
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
for their children) returns `möglich` with the same price as the control where the Anmelder is
`teilnehmer 1`. Whether the committed booking binds to the Anmelder's existing customer record has not
been measured yet.
Note `650` again: both of those are hard rejections delivered under the code Note `650` again: both of those are hard rejections delivered under the code
`Notification::isError()` treats as success. `Notification::isError()` treats as success.
@@ -196,7 +208,7 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) | | `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below | | `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room | | `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()` | | `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` | | `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
| `teilnehmerliste` | yes | yes | | | `teilnehmerliste` | yes | yes | |
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | | | `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
+35 -5
View File
@@ -22,12 +22,42 @@ Symfony roles:
| `1293` | `ROLE_MANAGER` | | `1293` | `ROLE_MANAGER` |
| `1477` | `ROLE_GROUPS_MANAGER` | | `1477` | `ROLE_GROUPS_MANAGER` |
| `1478` | `ROLE_GROUPS_ADMIN` | | `1478` | `ROLE_GROUPS_ADMIN` |
| `1483` | `ROLE_CUSTOMER_EXPERT` |
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` | | label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
| nothing matched | `ROLE_CUSTOMER` | | nothing matched | `ROLE_CUSTOMER` |
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
one of these roles. one of these roles.
These are *claims*, not grants. What an account ends up holding is decided by
`Role::sync()` (`src/Security/Role.php`) on every login — see
[From claim to role](#from-claim-to-role).
---
## From claim to role
BusPro backend users can edit their own CRM selections, so the claims above are
filtered before anything is stored:
| Role | Claimed without a staff address | Claimed with a staff address |
| --- | --- | --- |
| `ROLE_TEAMER` | granted | granted |
| `ROLE_HOUSE_MANAGER` | nominated | nominated |
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
- **Staff address**: the login email's domain is listed in `employee_email_domains`
(`config/services.yaml`, exact match, never a suffix). Such an account also gets
`ROLE_EMPLOYEE`, which is derived from the address and not from the CRM.
- **Nominated**: stored as the marker `ROLE_X_PENDING`, which grants nothing until an
administrator approves it in `/admin/user`. Nominations are announced to
`role_nomination_notification_emails`.
- **Ignored**: treated exactly like a selection that is not set. No marker is stored,
and a role approved earlier is **revoked** on the next login. A leftover marker from
before the rule cannot be approved and disappears on that login too.
- Anything the CRM stops claiming is revoked, approved or not.
- An account left without any effective role gets `ROLE_CUSTOMER`.
--- ---
## The four tables ## The four tables
@@ -397,11 +427,11 @@ BusProNet host is the fix, and no schema change is needed.
## Portal side: a correct XML is not enough ## Portal side: a correct XML is not enough
`BpnAuthenticator::createOrUpdateLocalUser()` snapshots the roles onto the local `User` `BpnAuthenticator::syncFromCrm()` writes the roles onto the local `User` entity at login,
entity at login (`setRoles($crmAttributes->roles)` / through `Role::sync()` (see [From claim to role](#from-claim-to-role)), and the hotel codes
`setHotelCodes($crmAttributes->hotelCodes)`). Roles are **not** re-read per request. So verbatim. Roles are **not** re-read per request. So after fixing BusPro the affected user
after fixing BusPro the affected user must **log out and log in again** before must **log out and log in again**, and even then `ROLE_GROUPS_ADMIN` only takes effect once
`ROLE_GROUPS_ADMIN` takes effect — an active session keeps the stale role set. the account has a staff address and an administrator has approved the nomination.
--- ---
+73 -24
View File
@@ -602,10 +602,12 @@ class ApiClient
'port' => $this->selectedPort, 'port' => $this->selectedPort,
]); ]);
$this->send($socket, $body); $this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
$responseXml = substr($response, 10); try {
$responseXml = $this->receive($socket);
} finally {
$this->disconnect($socket);
}
if (true === $debug || true === $this->config['debug']) { if (true === $debug || true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $responseXml); $this->dumpXmlToFile('response', $requestId, $responseXml);
@@ -714,18 +716,12 @@ class ApiClient
]); ]);
$this->send($socket, $body); $this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message try {
$xml = substr($response, 10); // the length header is consumed and verified by receive(), this is the payload
$xml = $this->receive($socket);
if ('' === $xml) { } finally {
$this->logger->error('Empty response body received from API (header-only response)', [ $this->disconnect($socket);
'request_id' => $requestId,
'raw_length' => strlen($response),
]);
throw new ApiClientException('Empty response body received from API');
} }
if (true === $debug || true === $this->config['debug']) { if (true === $debug || true === $this->config['debug']) {
@@ -735,14 +731,19 @@ class ApiClient
try { try {
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs); return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
} catch (ResponseParserException $e) { } catch (ResponseParserException $e) {
// Keep the evidence even outside debug mode: without the raw response a parse
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
$this->dumpXmlToFile('response', $requestId, $xml); $this->dumpXmlToFile('response', $requestId, $xml);
$this->logger->error('Unable to parse response received from API', [
'request_id' => $requestId,
'type' => $type,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]);
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
} }
$this->logger->error('Unexpected response received from API', [
'request_id' => $requestId,
]);
throw new ApiClientException('Unexpected response received from API');
} }
/** /**
@@ -773,10 +774,12 @@ class ApiClient
]); ]);
$this->send($socket, $body); $this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
$xml = substr($response, 10); try {
$xml = $this->receive($socket);
} finally {
$this->disconnect($socket);
}
if (true === $this->config['debug']) { if (true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml); $this->dumpXmlToFile('response', $requestId, $xml);
@@ -898,8 +901,14 @@ class ApiClient
} }
/** /**
* Reads a single response message and returns its payload. The protocol prepends the
* payload length as a 10 byte header, so the announced length is compared against what
* actually arrived: a peer closing mid-stream would otherwise yield a silently
* truncated body that only surfaces later as an unexplained parse failure.
*
* @param resource $socket * @param resource $socket
* *
* @throws ApiClientException
* @throws TimeoutException * @throws TimeoutException
* @throws ImmediateConnectionCloseException * @throws ImmediateConnectionCloseException
*/ */
@@ -963,7 +972,47 @@ class ApiClient
throw new ImmediateConnectionCloseException('Server closed connection without sending data'); throw new ImmediateConnectionCloseException('Server closed connection without sending data');
} }
return $response; return $this->extractPayload($response);
}
/**
* @throws ApiClientException
*/
private function extractPayload(string $response): string
{
if (10 > strlen($response)) {
$this->logger->error('Incomplete response header received from API', [
'bytes_received' => strlen($response),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
}
$header = substr($response, 0, 10);
$announcedLength = (int) trim($header);
$payload = substr($response, 10);
if (1 > $announcedLength) {
$this->logger->error('Empty response body announced by API', [
'header' => trim($header),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
}
if (strlen($payload) !== $announcedLength) {
$this->logger->error('Truncated response received from API', [
'bytes_received' => strlen($payload),
'announced_length' => $announcedLength,
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
}
return $payload;
} }
/** @param resource $socket */ /** @param resource $socket */
+25
View File
@@ -143,6 +143,31 @@ class Booking
}); });
} }
/**
* Checks whether a participant already holds a service in this booking.
*
* This is the BusPro-side baseline, not the working edit selection: it answers
* "did the participant have this service when the booking was last read back".
* Services already held may be kept regardless of their current status or
* contingent - withdrawing one produces "Anzahl Leistung stimmt nicht mit
* Teilnehmerzuordnung überein" on the next update.
*
* @param int $participantIndex The participant index to search for
* @param int $serviceId The service ID to look for
*
* @return bool True if the participant already holds the service
*/
public function hasServiceForParticipant(int $participantIndex, int $serviceId): bool
{
foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) {
if ($service->id === $serviceId && in_array($participantIndex, $service->mapping)) {
return true;
}
}
return false;
}
/** /**
* Retrieves transportation service for a specific participant and direction. * Retrieves transportation service for a specific participant and direction.
* *
+15 -1
View File
@@ -61,6 +61,13 @@ class PersonalData
/** @var list<string> */ /** @var list<string> */
public array $hotelCodes = []; public array $hotelCodes = [];
// The authenticated account, not BusPro's idea of this person. One BusPro person may sign in
// under any of the addresses on its record, and each of those is a separate local account with
// its own roles, so neither the BusPro ids nor the communication email identifies one. Patched
// on by the caller in the same way as the roles and hotel codes above.
public ?string $subject = null;
public ?string $loginEmail = null;
public function __construct() public function __construct()
{ {
$this->address = new Address(); $this->address = new Address();
@@ -132,14 +139,21 @@ class PersonalData
* Creates a structured array containing user profile information * Creates a structured array containing user profile information
* suitable for JWT claims or user session data. * suitable for JWT claims or user session data.
* *
* `sub` and `email` describe the account that authenticated and are read from the patched-on
* fields, never from BusPro: `person_id`/`address_id` are shared by every account of the same
* person, and `profile.communication.email` is the first contact address on the BusPro record,
* which is not necessarily the one signed in with. Deliberately without a fallback to that
* address — a caller that forgets to patch them gets null rather than a wrong identity.
*
* @return array<string, mixed> The claims array with user profile data * @return array<string, mixed> The claims array with user profile data
*/ */
public function getClaims(): array public function getClaims(): array
{ {
return [ return [
'sub' => $this->subject,
'person_id' => $this->personId, 'person_id' => $this->personId,
'address_id' => $this->addressId, 'address_id' => $this->addressId,
'email' => $this->communication->email, 'email' => $this->loginEmail,
'roles' => $this->roles, 'roles' => $this->roles,
'profile' => [ 'profile' => [
'first_name' => $this->firstName, 'first_name' => $this->firstName,
+34
View File
@@ -20,4 +20,38 @@ final class XmlCrawlerFactory
return $crawler; return $crawler;
} }
/**
* Explains why create() returned an empty Crawler. addXmlContent() discards the
* libxml errors, so an empty, a malformed and a truncated payload are
* indistinguishable afterwards; this re-parses to recover the reason. Only worth
* calling on the failure path.
*/
public static function diagnose(string $xml): string
{
if ('' === trim($xml)) {
return sprintf('empty response (%d bytes)', strlen($xml));
}
$previousUseErrors = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
simplexml_load_string($xml);
$messages = [];
foreach (libxml_get_errors() as $error) {
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
}
if ([] === $messages) {
return sprintf('unknown XML error (%d bytes)', strlen($xml));
}
return sprintf('%s (%d bytes)', implode('; ', array_unique($messages)), strlen($xml));
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previousUseErrors);
}
}
} }
+16 -5
View File
@@ -281,9 +281,9 @@ class TravelLoader extends AbstractLoader
/** /**
* Apply availability data to travel services. * Apply availability data to travel services.
* *
* Updates the availability status of additional and transportation * Updates the remaining contingent and the live status of additional and
* services, and the allowed booking status, based on the provided * transportation services, and the allowed booking status, based on the
* availability data. * provided availability data.
* *
* @param Travel $travel The travel object to update * @param Travel $travel The travel object to update
* @param ServiceAvailabilityResponse $availabilities The availability data for services * @param ServiceAvailabilityResponse $availabilities The availability data for services
@@ -293,8 +293,19 @@ class TravelLoader extends AbstractLoader
$serviceAvailabilities = $availabilities->getServices(); $serviceAvailabilities = $availabilities->getServices();
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) { foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
if (array_key_exists($service->id, $serviceAvailabilities)) { if (false === array_key_exists($service->id, $serviceAvailabilities)) {
$service->available = $serviceAvailabilities[$service->id]->available; continue;
}
$availability = $serviceAvailabilities[$service->id];
$service->available = $availability->available;
// The live status is the only signal that a contingent has run out: BusPro moves a
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the
// response actually carries one, so a response that omits the attribute does not wipe
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService().
if (null !== $availability->status && '' !== trim($availability->status)) {
$service->status = $availability->status;
} }
} }
@@ -17,7 +17,7 @@ class ApiResponseParser extends AbstractParser
$crawler = XmlCrawlerFactory::create($xml); $crawler = XmlCrawlerFactory::create($xml);
if (0 === $crawler->count()) { if (0 === $crawler->count()) {
throw new ResponseParserException('Empty response received from server'); throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
} }
// Override type unless present in XML to catch error responses // Override type unless present in XML to catch error responses
@@ -56,7 +56,8 @@ class ApiResponseParser extends AbstractParser
case 'Vorgangdruck': case 'Vorgangdruck':
return (new DocumentsParser())->parseConfirmation($resultNode); return (new DocumentsParser())->parseConfirmation($resultNode);
} }
break;
throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
case ApiClient::TYPE_BASE_DATA_COUNTRIES: case ApiClient::TYPE_BASE_DATA_COUNTRIES:
return (new CountriesParser())->parse($resultNode); return (new CountriesParser())->parse($resultNode);
case ApiClient::TYPE_MUTABLE_DATA: case ApiClient::TYPE_MUTABLE_DATA:
@@ -92,6 +93,6 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e); throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
} }
throw new ResponseParserException('Unable to parse XML response'); throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
} }
} }
@@ -26,11 +26,21 @@ class CrmAttributesResponseParser
{ {
use TypeConversionTrait; use TypeConversionTrait;
private const BPN_CRM_ID_ADMIN = 1292; /**
private const BPN_CRM_ID_MANAGER = 1293; * The CRM selections that stand for a role, by selection id. "Hausleitung" is deliberately not
private const BPN_CRM_ID_TEAMER = 1070; * in here: its ids are deployment configuration (see $houseManagerIds) and carry a hotel code
private const BPN_CRM_ID_GROUPS_MANAGER = 1477; * on top of the role.
private const BPN_CRM_ID_GROUPS_ADMIN = 1478; *
* @var array<int, string> selection id => role
*/
private const ROLE_BY_CRM_ID = [
1070 => Role::TEAMER,
1292 => Role::ADMIN,
1293 => Role::MANAGER,
1477 => Role::GROUPS_MANAGER,
1478 => Role::GROUPS_ADMIN,
1483 => Role::CUSTOMER_EXPERT,
];
/** /**
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code * @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
@@ -71,20 +81,11 @@ class CrmAttributesResponseParser
$roles[] = Role::HOUSE_MANAGER; $roles[] = Role::HOUSE_MANAGER;
$hotelCodes[] = $hotelCode; $hotelCodes[] = $hotelCode;
} }
if (self::BPN_CRM_ID_ADMIN === $attribute->id) {
$roles[] = Role::ADMIN; $role = self::ROLE_BY_CRM_ID[$attribute->id] ?? null;
}
if (self::BPN_CRM_ID_MANAGER === $attribute->id) { if (null !== $role) {
$roles[] = Role::MANAGER; $roles[] = $role;
}
if (self::BPN_CRM_ID_TEAMER === $attribute->id) {
$roles[] = Role::TEAMER;
}
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id) {
$roles[] = Role::GROUPS_MANAGER;
}
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id) {
$roles[] = Role::GROUPS_ADMIN;
} }
} }
+489
View File
@@ -0,0 +1,489 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\BusProNet\ApiClient;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Repository\BookingEditDraftRepository;
use App\Security\Crypt;
use App\Service\BookingChangeTracker;
use App\Service\BookingEditDraftMerger;
use App\Service\TravelDataProvider;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Repairs a booking edit draft that BusPro keeps rejecting.
*
* A draft survives every failed submission and is replayed on each re-entry into the edit flow.
* When one selection inside it has become unacceptable to BusPro - a Leistung whose status has
* drifted to "Anfrage" because its contingent ran out, say - the whole change is refused, nothing
* persists, and the customer is stuck in a loop they cannot edit their way out of. Booking 98787
* accumulated 37 such failures over two months.
*
* The valuable half of that draft is the personal data: names, dates of birth, contact details,
* addresses, room remarks. Re-entering it by hand for eighty participants is not a reasonable ask.
* So this command rebuilds the draft on top of the booking as BusPro currently holds it, keeps
* every field the customer typed, and reverts only the service selections that BusPro will not
* accept - naming each one, so the office can tell the customer what to pick again.
*
* Dry run by default. --apply writes a JSON backup of the original form data first.
*/
#[AsCommand(
name: 'app:booking:repair-draft',
description: 'Rebuild a rejected booking edit draft, keeping typed data and reverting unacceptable service selections',
)]
class BookingRepairDraftCommand extends Command
{
/**
* Draft `services` keys holding a single service id.
*/
/**
* Date of birth the office writes into the empty slots of a template booking.
*/
private const PLACEHOLDER_DATE_OF_BIRTH = '2000-01-01';
private const SINGLE_SERVICE_KEYS = ['skiPass', 'veg', 'insurance', 'rentalInsurance'];
/**
* Draft `services` keys holding a list of service ids.
*
* @var list<string>
*/
private const LIST_SERVICE_KEYS = ['courses', 'board', 'rentals', 'additionalServices'];
/**
* Transport is reverted as a unit: a participant put back on their own arrival must lose the
* pickup and drop-off that only make sense on a coach.
*
* @var list<string>
*/
private const TRANSPORT_KEYS = ['transportationOutbound', 'transportationInbound', 'pickup', 'dropOff'];
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager,
private readonly ApiClient $apiClient,
private readonly Crypt $crypt,
private readonly TravelDataProvider $travelDataProvider,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly BookingEditDraftMerger $draftMerger,
private readonly BookingChangeTracker $changeTracker,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('booking', 'b', InputOption::VALUE_REQUIRED, 'idbuchung whose draft should be repaired')
->addOption('apply', null, InputOption::VALUE_NONE, 'Write the repaired draft. Without this the command only reports')
->addOption('backup-dir', null, InputOption::VALUE_REQUIRED, 'Where to write the backup of the original form data', 'var/draft-backups')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$bookingId = $input->getOption('booking');
if (null === $bookingId) {
$io->error('--booking is required.');
return Command::INVALID;
}
$draft = $this->findSingleDraft($io, (int) $bookingId);
if (null === $draft) {
return Command::FAILURE;
}
$user = $draft->getUser();
$booking = $this->fetchLiveBooking($io, $user, (int) $bookingId);
if (null === $booking) {
return Command::FAILURE;
}
$travel = $this->travelDataProvider->getTravelData((int) $booking->dateId, $booking->hotelId, true);
if (null === $travel) {
$io->error(sprintf('No travel data for date id %d.', (int) $booking->dateId));
return Command::FAILURE;
}
$formData = $draft->getFormData();
$draftParticipants = $formData['participants'] ?? [];
if (false === $this->assertAlignment($io, $booking, $draftParticipants)) {
return Command::FAILURE;
}
$blocked = $this->collectUnbookableServices($io, $travel);
// The booking as BusPro currently holds it is the only state known to be acceptable, so
// it is the base everything is rebuilt on.
$repairedDto = $this->bookingDataProcessor->createBookingDtoFromBooking(
$booking,
$travel,
false,
);
$liveSelections = $this->changeTracker->extractUserData($repairedDto)['participants'] ?? [];
$reverted = [];
foreach ($draftParticipants as $index => $participantData) {
if (false === isset($repairedDto->participants[$index])) {
continue;
}
$filtered = $this->filterServiceSelections(
$participantData,
$liveSelections[$index]['services'] ?? [],
$blocked,
$index,
$reverted,
);
$this->draftMerger->apply($repairedDto, $index, $repairedDto->participants[$index], $filtered, $travel);
}
$repaired = $this->changeTracker->extractUserData($repairedDto);
$this->report($io, $formData, $repaired, $reverted, $blocked);
if (false === $input->getOption('apply')) {
$io->note('Dry run. Re-run with --apply to write the repaired draft.');
return Command::SUCCESS;
}
$backupPath = $this->writeBackup($draft, (string) $input->getOption('backup-dir'));
$io->success(sprintf('Original form data backed up to %s', $backupPath));
$draft->setFormData($repaired);
$this->entityManager->flush();
$io->success(sprintf('Draft %d repaired.', (int) $draft->getId()));
return Command::SUCCESS;
}
private function findSingleDraft(SymfonyStyle $io, int $bookingId): ?BookingEditDraft
{
$drafts = $this->draftRepository->findBy(['bookingId' => $bookingId]);
if ([] === $drafts) {
$io->error(sprintf('No draft found for booking %d.', $bookingId));
return null;
}
if (count($drafts) > 1) {
$io->error(sprintf('Booking %d has %d drafts; resolve by hand.', $bookingId, count($drafts)));
return null;
}
$draft = $drafts[0];
$io->definitionList(
['Draft' => sprintf('%d (user %d)', (int) $draft->getId(), (int) $draft->getUser()->getId())],
['Created' => $draft->getCreatedAt()->format('Y-m-d H:i')],
['Last saved' => $draft->getUpdatedAt()->format('Y-m-d H:i')],
['Participants' => (string) count($draft->getFormData()['participants'] ?? [])],
);
return $draft;
}
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
{
$result = $this->apiClient->getBooking(
(string) $user->getEmail(),
$this->crypt->decrypt((string) $user->getPassword()),
$bookingId,
);
if ($result instanceof Notification) {
$io->error(sprintf('Vorgang_Details failed: %d %s', (int) $result->code, (string) $result->message));
return null;
}
return $result;
}
/**
* Refuses to repair a draft whose positions no longer line up with the live booking.
*
* Drafts are merged positionally, by array index, with no identity check. Empty template
* slots are interchangeable, so they prove nothing; the participants BusPro already knows by
* date of birth are the only anchors available. If one of those has moved, every later
* position is suspect and rebuilding would quietly graft data onto the wrong people.
*
* @param array<int, array<string, mixed>> $draftParticipants
*/
private function assertAlignment(SymfonyStyle $io, Booking $booking, array $draftParticipants): bool
{
$liveCount = count($booking->participants);
$draftCount = count($draftParticipants);
if ($liveCount !== $draftCount) {
$io->warning(sprintf(
'Live booking has %d participants, the draft has %d. Seats were added or removed since the draft was written.',
$liveCount,
$draftCount,
));
}
$anchors = 0;
$mismatches = [];
foreach (array_values($booking->participants) as $index => $participant) {
$liveDob = $participant->dateOfBirth?->format('Y-m-d');
if (null === $liveDob || self::PLACEHOLDER_DATE_OF_BIRTH === $liveDob) {
continue;
}
++$anchors;
$draftDob = $draftParticipants[$index]['personalData']['dateOfBirth'] ?? null;
if (null !== $draftDob && $draftDob !== $liveDob) {
$mismatches[] = sprintf('position %d: live %s, draft %s', $index + 1, $liveDob, $draftDob);
}
}
if ([] !== $mismatches) {
$io->error('Draft positions no longer match the live booking:');
$io->listing($mismatches);
$io->comment('Repairing would move typed data onto the wrong participants. Resolve by hand.');
return false;
}
$io->text(sprintf('Alignment verified against %d participant(s) BusPro already knows.', $anchors));
return true;
}
/**
* Collects the services BusPro will not accept as an addition.
*
* A Leistung only takes new participants while its own status is "Frei"; anything else is the
* condition behind "Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers".
* Live availability is preferred over the travel data, which lags behind it.
*
* @return array<int, string> service id => status
*/
private function collectUnbookableServices(SymfonyStyle $io, Travel $travel): array
{
$statuses = [];
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
if (null !== $service->id && null !== $service->status) {
$statuses[$service->id] = $service->status;
}
}
$live = $this->apiClient->getAvailabilitiesExtended((int) $travel->id);
if ($live instanceof Notification) {
$io->warning(sprintf(
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
(int) $live->code,
(string) $live->message,
));
} else {
foreach ($live->getServices() as $availability) {
if (null !== $availability->serviceId && null !== $availability->status && '' !== trim($availability->status)) {
$statuses[$availability->serviceId] = $availability->status;
}
}
}
$blocked = array_filter($statuses, static fn (string $status): bool => Constants::STATUS_AVAILABLE !== $status);
if ([] === $blocked) {
$io->text('Every service on this travel is currently "Frei".');
return [];
}
$rows = [];
foreach ($blocked as $serviceId => $status) {
$service = $travel->additionalServices[$serviceId] ?? $travel->transportationServices[$serviceId] ?? null;
$label = $service instanceof Service ? (string) $service->label : '?';
$rows[] = [$serviceId, $label, $status];
}
$io->section('Services that cannot take new participants');
$io->table(['id', 'Leistung', 'status'], $rows);
return $blocked;
}
/**
* Drops the draft's selections that would add a participant to a service BusPro has closed.
*
* Removing a key leaves the live booking's own value in place, because the merger applies
* only the keys it is given. Selections BusPro still accepts - a different ski pass, a meal
* preference - are kept, so the customer loses as little as possible.
*
* @param array<string, mixed> $participantData
* @param array<string, mixed> $liveServices
* @param array<int, string> $blocked
* @param list<string> $reverted
*
* @return array<string, mixed>
*/
private function filterServiceSelections(
array $participantData,
array $liveServices,
array $blocked,
int $index,
array &$reverted,
): array {
if (false === isset($participantData['services']) || [] === $blocked) {
return $participantData;
}
$services = $participantData['services'];
$position = $index + 1;
foreach (self::SINGLE_SERVICE_KEYS as $key) {
$selected = $services[$key] ?? null;
if (null !== $selected && isset($blocked[$selected]) && ($liveServices[$key] ?? null) !== $selected) {
unset($services[$key]);
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
}
}
foreach (self::LIST_SERVICE_KEYS as $key) {
$selected = $services[$key] ?? null;
if (false === is_array($selected)) {
continue;
}
$liveList = $liveServices[$key] ?? [];
$kept = [];
foreach ($selected as $serviceId) {
if (isset($blocked[$serviceId]) && false === in_array($serviceId, $liveList, true)) {
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $serviceId, $blocked[$serviceId]);
continue;
}
$kept[] = $serviceId;
}
$services[$key] = $kept;
}
foreach (['transportationOutbound', 'transportationInbound'] as $key) {
$selected = $services[$key] ?? null;
if (null === $selected || false === isset($blocked[$selected]) || ($liveServices[$key] ?? null) === $selected) {
continue;
}
$reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]);
// Transport reverts as a unit, pickup and drop-off included.
foreach (self::TRANSPORT_KEYS as $transportKey) {
unset($services[$transportKey]);
}
break;
}
$participantData['services'] = $services;
return $participantData;
}
/**
* @param array<string, mixed> $original
* @param array<string, mixed> $repaired
* @param list<string> $reverted
* @param array<int, string> $blocked
*/
private function report(SymfonyStyle $io, array $original, array $repaired, array $reverted, array $blocked): void
{
$io->section('Typed data preserved');
$fields = ['firstName', 'lastName', 'dateOfBirth', 'email', 'mobile'];
$kept = 0;
$lost = [];
foreach ($original['participants'] ?? [] as $index => $participantData) {
foreach ($fields as $field) {
$before = $participantData['personalData'][$field] ?? null;
if (null === $before || '' === $before) {
continue;
}
$after = $repaired['participants'][$index]['personalData'][$field] ?? null;
if ($before === $after) {
++$kept;
continue;
}
$lost[] = sprintf('participant %d: %s', $index + 1, $field);
}
}
$io->text(sprintf('<info>%d</info> personal-data field(s) carried over.', $kept));
if ([] !== $lost) {
$io->warning(sprintf('%d field(s) could NOT be carried over:', count($lost)));
$io->listing(array_slice($lost, 0, 25));
}
$io->section('Service selections reverted');
if ([] === $reverted) {
$io->text([] === $blocked
? 'None - no service on this travel is closed.'
: 'None - the draft selects no closed service.');
return;
}
$io->warning(sprintf('%d selection(s) reverted to the booked state. The customer must choose again:', count($reverted)));
$io->listing($reverted);
}
private function writeBackup(BookingEditDraft $draft, string $backupDir): string
{
$directory = $this->projectDir.'/'.trim($backupDir, '/');
if (false === is_dir($directory)) {
mkdir($directory, 0o775, true);
}
$path = sprintf(
'%s/draft-%d-booking-%d-%s.json',
$directory,
(int) $draft->getId(),
$draft->getBookingId(),
(new \DateTimeImmutable())->format('Ymd-His'),
);
file_put_contents($path, json_encode($draft->getFormData(), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE));
return $path;
}
}
@@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_CUSTOMER_EXPERT')]
class DeleteController extends AbstractController class DeleteController extends AbstractController
{ {
use ReturnUrlTrait; use ReturnUrlTrait;
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_CUSTOMER_EXPERT')]
class ExportController extends AbstractController class ExportController extends AbstractController
{ {
use ReturnUrlTrait; use ReturnUrlTrait;
@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_CUSTOMER_EXPERT')]
class FilterController extends AbstractController class FilterController extends AbstractController
{ {
use ListFilterTrait; use ListFilterTrait;
@@ -16,7 +16,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_CUSTOMER_EXPERT')]
class IndexController extends AbstractController class IndexController extends AbstractController
{ {
use ListFilterTrait; use ListFilterTrait;
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_CUSTOMER_EXPERT')]
class ShowController extends AbstractController class ShowController extends AbstractController
{ {
use ReturnUrlTrait; use ReturnUrlTrait;
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_CUSTOMER_EXPERT')]
class DownloadController extends AbstractController class DownloadController extends AbstractController
{ {
public function __construct(private readonly XmlDumpReader $xmlDumpReader) public function __construct(private readonly XmlDumpReader $xmlDumpReader)
@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_CUSTOMER_EXPERT')]
class FilterController extends AbstractController class FilterController extends AbstractController
{ {
use ListFilterTrait; use ListFilterTrait;
+1 -1
View File
@@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_CUSTOMER_EXPERT')]
class IndexController extends AbstractController class IndexController extends AbstractController
{ {
use ListFilterTrait; use ListFilterTrait;
@@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_CUSTOMER_EXPERT')]
class XmlDumpController extends AbstractController class XmlDumpController extends AbstractController
{ {
public function __construct(private readonly XmlDumpReader $xmlDumpReader) public function __construct(private readonly XmlDumpReader $xmlDumpReader)
+10 -2
View File
@@ -26,8 +26,9 @@ class UserinfoController extends AbstractController
#[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])] #[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])]
public function index(): JsonResponse public function index(): JsonResponse
{ {
// basic scopes applicable to all authenticated users // basic scopes applicable to all authenticated users. `sub` is not gated on a scope of its
$scopes = ['email']; // own: it identifies the account every other claim describes, so it is always exported.
$scopes = ['sub', 'email'];
// extend scopes depending on granted permissions // extend scopes depending on granted permissions
if ($this->isGranted('ROLE_OAUTH2_ID')) { if ($this->isGranted('ROLE_OAUTH2_ID')) {
@@ -53,6 +54,13 @@ class UserinfoController extends AbstractController
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST); return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
} }
// Patch the identity of the account that authenticated. BusPro accepts any of the
// addresses on a person's record as a login and answers all of them with the same
// ids and the same first contact address, so only the local account tells the staff
// account and the private one apart — and they hold different roles.
$data->subject = (string) $user->getId();
$data->loginEmail = $user->getEmail();
// Patch current user's roles. The implicit ROLE_USER says nothing about the // Patch current user's roles. The implicit ROLE_USER says nothing about the
// account — every authenticated user holds it — and is not exported. // account — every authenticated user holds it — and is not exported.
$data->roles = Role::effectiveOnly($user->getRoles()); $data->roles = Role::effectiveOnly($user->getRoles());
@@ -116,6 +116,14 @@ class IndexController extends AbstractController
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.'); $this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
} }
$droppedDraftServiceLabels = $this->dataLoader->getDroppedDraftServiceLabels();
if ([] !== $droppedDraftServiceLabels) {
$this->addFlash('info', sprintf(
'Folgende Leistungen aus deinem gespeicherten Entwurf sind inzwischen ausgebucht oder nur auf Anfrage verfügbar und konnten nicht übernommen werden: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
implode(', ', $droppedDraftServiceLabels)
));
}
// Fetch booking data for display (surcharges, canceled status, etc.) // Fetch booking data for display (surcharges, canceled status, etc.)
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user); $bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
if (null === $bookingData || $bookingData instanceof Notification) { if (null === $bookingData || $bookingData instanceof Notification) {
@@ -185,6 +193,13 @@ class IndexController extends AbstractController
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.'); $this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
} }
if ([] !== $result->unbookableServicesReverted) {
$this->addFlash('info', sprintf(
'Folgende Leistungen konnten nicht hinzugebucht werden, da sie inzwischen ausgebucht oder nur auf Anfrage verfügbar sind: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
implode(', ', $result->unbookableServicesReverted)
));
}
switch ($result->status) { switch ($result->status) {
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED: case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden'); $this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as SymfonyAbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractController extends SymfonyAbstractController
{
use HxTrait;
/**
* A draft and an inquiry the office has not worked through yet are not ready to be
* shown, and a discarded booking must not be viewable any more — in all three cases
* the link behaves as if it had expired.
*/
protected function isCustomerVisible(AccommodationBooking $booking): bool
{
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
}
/**
* Sends the browser to a full reload of the (non-modal) offer page — this is
* triggered from an htmx-loaded modal, so a plain render/redirect here would
* get appended as an inert HTML fragment instead of actually navigating.
*/
protected function redirectToOfferPage(Request $request, string $uuid): Response
{
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
return $this->htmxRedirect($request, $url);
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Form\Model\OfferAcceptDto;
use App\Form\OfferAcceptConfirmationType;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ConfirmController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
) {
}
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
// Offen is the one status that means an offer is out and awaiting acceptance, so it
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
// Checked for POST as well, so the modal cannot be submitted around the contact page.
if (!$this->bookingService->hasCompleteContactData($booking)) {
return $this->htmxRedirect($request, $this->generateUrl('app_groups_offer_contact', ['uuid' => $uuid]));
}
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
]);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
// The textarea is prefilled, so the submitted value is the complete remark —
// an emptied field arrives as null and must clear the stored one, not keep it.
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
return $this->redirectToOfferPage($request, $uuid);
}
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
'booking' => $booking,
'confirmationForm' => $confirmationForm,
]);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Form\AccommodationContactType;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ContactController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingService $bookingService,
) {
}
/**
* Full page rather than modal: the form is as large as step 3 of a self-service booking,
* whose fields and constraints it shares. Once saved, the customer is back on the offer
* and accepts it from there as usual.
*/
#[Route(path: '/groups/booking/offer/{uuid}/contact', name: 'app_groups_offer_contact', methods: ['GET', 'POST'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
$dto = $this->bookingService->contactDataFromBooking($booking);
$form = $this->createForm(AccommodationContactType::class, $dto, [
'email_readonly' => true,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingService->applyContactData($booking, $dto);
$this->addFlash('success', 'Deine Kontaktdaten wurden gespeichert. Du kannst die Buchung jetzt abschließen.');
return $this->redirectToOfferPage($request, $uuid);
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
return $this->render('groups/offer/contact.html.twig', [
'booking' => $booking,
'priceBreakdown' => $ctx->priceBreakdown,
'ctx' => $ctx,
'form' => $form,
]);
}
}
+1 -102
View File
@@ -4,17 +4,8 @@ declare(strict_types=1);
namespace App\Controller\Groups\Offer; namespace App\Controller\Groups\Offer;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Model\OfferAcceptDto;
use App\Form\OfferAcceptConfirmationType;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationBookingRepository; use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner; use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -22,14 +13,9 @@ use Symfony\Component\Routing\Attribute\Route;
class IndexController extends AbstractController class IndexController extends AbstractController
{ {
use HxTrait;
public function __construct( public function __construct(
private readonly AccommodationBookingRepository $bookingRepository, private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner, private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
) { ) {
} }
@@ -39,7 +25,7 @@ class IndexController extends AbstractController
* (session-gated) offer page — nothing downstream needs the signature again. * (session-gated) offer page — nothing downstream needs the signature again.
*/ */
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_offer', methods: ['GET'])] #[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_offer', methods: ['GET'])]
public function access(string $uuid, Request $request): Response public function index(string $uuid, Request $request): Response
{ {
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
@@ -55,91 +41,4 @@ class IndexController extends AbstractController
return new RedirectResponse($this->generateUrl('app_groups_offer_view', ['uuid' => $uuid])); return new RedirectResponse($this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]));
} }
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
public function view(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
if (!$this->isCustomerVisible($booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$priceBreakdown = $this->breakdownCalculator->compute($booking);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->render('groups/offer/view.html.twig', [
'booking' => $booking,
'priceBreakdown' => $priceBreakdown,
'ctx' => $ctx,
]);
}
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
public function confirm(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
// Offen is the one status that means an offer is out and awaiting acceptance, so it
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
]);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
// The textarea is prefilled, so the submitted value is the complete remark —
// an emptied field arrives as null and must clear the stored one, not keep it.
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
return $this->redirectToOfferPage($request, $uuid);
}
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
'booking' => $booking,
'confirmationForm' => $confirmationForm,
]);
}
/**
* A draft and an inquiry the office has not worked through yet are not ready to be
* shown, and a discarded booking must not be viewable any more — in all three cases
* the link behaves as if it had expired.
*/
private function isCustomerVisible(AccommodationBooking $booking): bool
{
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
}
/**
* Sends the browser to a full reload of the (non-modal) offer page — this is
* triggered from an htmx-loaded modal, so a plain render/redirect here would
* get appended as an inert HTML fragment instead of actually navigating.
*/
private function redirectToOfferPage(Request $request, string $uuid): Response
{
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
return $this->htmxRedirect($request, $url);
}
} }
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ViewController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
if (!$this->isCustomerVisible($booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
return $this->render('groups/offer/view.html.twig', [
'booking' => $booking,
'priceBreakdown' => $ctx->priceBreakdown,
'ctx' => $ctx,
]);
}
}
@@ -37,7 +37,7 @@ class RecentLogEntriesWidgetProvider implements DashboardWidgetProviderInterface
public function getRequiredRole(): string public function getRequiredRole(): string
{ {
return Role::ADMIN; return Role::CUSTOMER_EXPERT;
} }
public function getPriority(): int public function getPriority(): int
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Dashboard\Widget;
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
use App\Entity\BookingEditDraft;
use App\Model\DashboardWidget;
use App\Model\DashboardWidgetEntry;
use App\Repository\BookingEditDraftRepository;
use App\Security\Role;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Customers whose changes cannot be saved.
*
* A draft is deleted as soon as an update succeeds, so one that has been around for days belongs to
* somebody BusPro keeps refusing — and nothing else tells anyone. One booking accumulated
* thirty-seven failed attempts across two months before it was noticed by accident; the customer
* had entered seventy-nine participants, none of which ever reached BusPro. The point of this card
* is that the next one gets noticed in the first week instead.
*
* Each entry links to the draft, since the age alone does not say what is going wrong.
*/
class StuckBookingDraftsWidgetProvider implements DashboardWidgetProviderInterface
{
/**
* How long a draft must have survived to be worth reporting.
*
* Short enough to catch a customer inside their first week of trying, long enough that an edit
* somebody merely abandoned over a weekend does not fill the card.
*/
private const MIN_AGE_DAYS = 7;
private const LIMIT = 10;
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly UrlGeneratorInterface $urlGenerator,
) {
}
public function getRequiredRole(): string
{
return Role::CUSTOMER_EXPERT;
}
public function getPriority(): int
{
return 95;
}
public function build(): ?DashboardWidget
{
$drafts = $this->draftRepository->findStuck(self::MIN_AGE_DAYS, self::LIMIT);
return new DashboardWidget(
'Festhängende Buchungsänderungen',
array_map(fn (BookingEditDraft $draft): DashboardWidgetEntry => new DashboardWidgetEntry(
$this->label($draft),
$this->urlGenerator->generate('app_admin_bookingeditdraft_show', ['id' => $draft->getId()]),
'edit',
), $drafts),
'Keine festhängenden Buchungsänderungen.',
$this->urlGenerator->generate('app_admin_bookingeditdraft'),
'Alle Buchungsentwürfe',
);
}
/**
* Names the booking, how long it has been stuck, and when the customer last tried.
*
* The gap between the two is what distinguishes an abandoned edit from somebody still trying
* every few days and getting nowhere.
*/
private function label(BookingEditDraft $draft): string
{
$days = $draft->getCreatedAt()->diff(new \DateTimeImmutable())->days ?? 0;
return sprintf(
'Vorgang %s (%s) — seit %d Tagen, zuletzt %s',
$draft->getBookingNumber() ?? $draft->getBookingId(),
(string) $draft->getUser()->getEmail(),
$days,
$draft->getUpdatedAt()->format('d.m.Y'),
);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The contact data of a group booking — shared by step 3 of the self-service flow and by
* the page that completes an office-made offer before it can be accepted, so both ask
* for the same fields under the same constraints.
*
* @extends AbstractType<AccommodationBookingDto>
*/
class AccommodationContactType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('groupName', TextType::class, [
'label' => 'Name der Gruppe',
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => 'Herr',
'Frau' => 'Frau',
'divers' => 'divers',
],
'expanded' => false,
'multiple' => false,
'placeholder' => 'Bitte wählen',
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('email', EmailType::class, [
'label' => 'E-Mail-Adresse',
// An offer's access link and every notification already go to this address.
'disabled' => $options['email_readonly'],
])
->add('phone', TextType::class, [
'label' => 'Telefon',
])
->add('street', TextType::class, [
'label' => 'Straße und Hausnummer',
])
->add('zip', TextType::class, [
'label' => 'Postleitzahl',
])
->add('city', TextType::class, [
'label' => 'Ort',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingDto::class,
'validation_groups' => ['contact'],
'email_readonly' => false,
]);
$resolver->setAllowedTypes('email_readonly', 'bool');
}
}
+5 -38
View File
@@ -6,10 +6,7 @@ namespace App\Form;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -21,41 +18,6 @@ class AccommodationStep3Type extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('groupName', TextType::class, [
'label' => 'Name der Gruppe',
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => 'Herr',
'Frau' => 'Frau',
'divers' => 'divers',
],
'expanded' => false,
'multiple' => false,
'placeholder' => 'Bitte wählen',
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('email', EmailType::class, [
'label' => 'E-Mail-Adresse',
])
->add('phone', TextType::class, [
'label' => 'Telefon',
])
->add('street', TextType::class, [
'label' => 'Straße und Hausnummer',
])
->add('zip', TextType::class, [
'label' => 'Postleitzahl',
])
->add('city', TextType::class, [
'label' => 'Ort',
])
->add('remarks', TextareaType::class, [ ->add('remarks', TextareaType::class, [
'label' => false, 'label' => false,
'required' => false, 'required' => false,
@@ -71,4 +33,9 @@ class AccommodationStep3Type extends AbstractType
'validation_groups' => ['step_3'], 'validation_groups' => ['step_3'],
]); ]);
} }
public function getParent(): string
{
return AccommodationContactType::class;
}
} }
+13 -11
View File
@@ -43,33 +43,35 @@ class AccommodationBookingDto
/** @var array<string, mixed> */ /** @var array<string, mixed> */
public array $priceBreakdown = []; public array $priceBreakdown = [];
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] // `contact` is the subset an office-made offer is checked against before the customer
// may accept it — the same constraints the customer meets in step 3 of a self-service booking.
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $groupName = null; public ?string $groupName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
#[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3'])] #[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3', 'contact'])]
public ?string $salutation = null; public ?string $salutation = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $firstName = null; public ?string $firstName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $lastName = null; public ?string $lastName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
#[Assert\Email(message: 'invalid', groups: ['step_3'])] #[Assert\Email(message: 'invalid', groups: ['step_3', 'contact'])]
public ?string $email = null; public ?string $email = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $phone = null; public ?string $phone = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $street = null; public ?string $street = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $zip = null; public ?string $zip = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])] #[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $city = null; public ?string $city = null;
public ?string $remarks = null; public ?string $remarks = null;
@@ -154,10 +154,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description; $attributes['data-description'] = $service->description;
} }
// Make readonly if service is unavailable (intelligently handles edit mode) // Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) { if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -213,9 +213,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
} }
// Make readonly if service is unavailable (only if not already mandatory) // Make readonly if service is unavailable (only if not already mandatory)
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) { if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -270,9 +270,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Make readonly if service is unavailable (only if not already mandatory) // Make readonly if service is unavailable (only if not already mandatory)
if (false === $service->mandatory if (false === $service->mandatory
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) { && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -330,10 +330,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $attributes; return $attributes;
} }
// Make readonly if service is unavailable (intelligently handles edit mode) // Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) { if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -392,9 +392,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// Make readonly if service is unavailable (only if not already mandatory) // Make readonly if service is unavailable (only if not already mandatory)
if (false === $service->mandatory if (false === $service->mandatory
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) { && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -527,10 +527,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description; $attributes['data-description'] = $service->description;
} }
// Make readonly if service is unavailable (intelligently handles edit mode) // Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) { if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -567,10 +567,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$attributes['data-description'] = $service->description; $attributes['data-description'] = $service->description;
} }
// Make readonly if service is unavailable (intelligently handles edit mode) // Make readonly if the service is not bookable for this participant
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) { if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
$attributes['readonly'] = true; $attributes['readonly'] = true;
$attributes['data-tooltip'] = 'ausgebucht'; $attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
} }
return $attributes; return $attributes;
@@ -661,9 +661,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$fieldOptions['attr']['data-description'] = $parkingService->description; $fieldOptions['attr']['data-description'] = $parkingService->description;
} }
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex, 'parking')) { if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex)) {
$fieldOptions['attr']['readonly'] = true; $fieldOptions['attr']['readonly'] = true;
$fieldOptions['attr']['data-tooltip'] = 'ausgebucht'; $fieldOptions['attr']['data-tooltip'] = $this->getUnavailabilityTooltip($parkingService, $bookingDto);
} }
return $fieldOptions; return $fieldOptions;
@@ -863,66 +863,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
*/ */
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{ {
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
// For non-create workflows, don't apply availability restrictions
return false;
}
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex); return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
} }
/** /**
* Determines if a service should be rendered as read-only. * Determines if a service should be rendered as read-only.
* *
* This method intelligently handles readonly state for services in both create and edit modes: * Both modes share one rule set. The already-held carve-out inside
* * ServiceAvailabilityCalculator::isServiceUnavailable() is what lets a participant keep a
* - CREATE MODE: Uses existing availability calculator logic * service that is now sold out or on request, so no mode-specific branch is needed here.
* - EDIT MODE: Services unavailable (available <= 0) are readonly ONLY if participant doesn't already have them
*
* This prevents fingerprint false positives in edit mode by allowing participants to keep
* services they already have, even if those services are now fully booked.
* *
* @param Service $service The service to check * @param Service $service The service to check
* @param BookingDto $bookingDto The booking DTO containing participant data * @param BookingDto $bookingDto The booking DTO containing participant data
* @param int $participantIndex Index of the participant currently selecting services * @param int $participantIndex Index of the participant currently selecting services
* @param string $fieldName Name of the service field (e.g., 'courses', 'board', 'rentals')
* *
* @return bool True if the service should be read-only * @return bool True if the service should be read-only
*/ */
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{ {
// In CREATE mode, use existing availability logic return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
}
// In EDIT mode, apply intelligent readonly logic
// If service is available (available > 0), it's never readonly
if (null !== $service->available && $service->available > 0) {
return false;
}
// Service is unavailable - check if participant already has it
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return true; // Readonly if no participant data
}
// Check if participant has this service based on field type
$participantHasService = match ($fieldName) {
'courses' => $this->hasServiceById($participant->courses, $service->id),
'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
'board' => $this->hasServiceById($participant->board, $service->id),
'veg' => $participant->veg?->id === $service->id,
'rentals' => $this->hasServiceById($participant->rentals, $service->id),
'skiPass' => $participant->skiPass?->id === $service->id,
'transportationOutbound' => $participant->transportationOutbound?->id === $service->id,
'transportationInbound' => $participant->transportationInbound?->id === $service->id,
default => false,
};
// Make readonly only if participant doesn't have it
return false === $participantHasService;
} }
/** /**
@@ -944,6 +903,23 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return false; return false;
} }
/**
* Returns the tooltip explaining why a service is rendered read-only.
*
* Only called for a service that is already known to be blocked, so the status alone
* identifies the reason. Create keeps the pre-existing wording, since the on-request rule
* never fires there.
*/
private function getUnavailabilityTooltip(Service $service, BookingDto $bookingDto): string
{
if (BookingDto::MODE_EDIT === $bookingDto->getMode()
&& Constants::STATUS_ON_REQUEST === $service->status) {
return 'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.';
}
return 'ausgebucht';
}
/** /**
* Filters services based on participant's age constraints. * Filters services based on participant's age constraints.
* *
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Menu; namespace App\Menu;
use App\Security\Voter\AdministrativeAccessVoter;
use Knp\Menu\FactoryInterface; use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface; use Knp\Menu\ItemInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -39,7 +40,7 @@ abstract class AbstractMenuBuilder
protected function addAdminItem(ItemInterface $menu): void protected function addAdminItem(ItemInterface $menu): void
{ {
if ($this->security->isGranted('ROLE_ADMIN')) { if ($this->security->isGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)) {
$this->addDivider($menu); $this->addDivider($menu);
$menu->addChild('zum Adminbereich', [ $menu->addChild('zum Adminbereich', [
'route' => 'app_admin_index', 'route' => 'app_admin_index',
+35 -29
View File
@@ -22,16 +22,18 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'icon' => 'chart', 'icon' => 'chart',
], ],
]); ]);
$menu->addChild('Buchungsentwürfe', [ if ($this->security->isGranted('ROLE_CUSTOMER_EXPERT')) {
'route' => 'app_admin_bookingeditdraft', $menu->addChild('Buchungsentwürfe', [
'linkAttributes' => [ 'route' => 'app_admin_bookingeditdraft',
'title' => 'Buchungsentwürfe', 'linkAttributes' => [
], 'title' => 'Buchungsentwürfe',
'extras' => [ ],
'icon' => 'edit', 'extras' => [
'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']], 'icon' => 'edit',
], 'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']],
]); ],
]);
}
if ($this->security->isGranted('ROLE_GROUPS_ADMIN')) { if ($this->security->isGranted('ROLE_GROUPS_ADMIN')) {
$menu->addChild('Gruppenbuchungen', [ $menu->addChild('Gruppenbuchungen', [
@@ -56,25 +58,29 @@ class AdminMenuBuilder extends AbstractMenuBuilder
]); ]);
} }
$menu->addChild('Benutzer', [ if ($this->security->isGranted('ROLE_ADMIN')) {
'route' => 'app_admin_user', $menu->addChild('Benutzer', [
'linkAttributes' => [ 'route' => 'app_admin_user',
'title' => 'Benutzer', 'linkAttributes' => [
], 'title' => 'Benutzer',
'extras' => [ ],
'icon' => 'users', 'extras' => [
], 'icon' => 'users',
]); ],
$menu->addChild('Logs', [ ]);
'route' => 'app_admin_log', }
'linkAttributes' => [ if ($this->security->isGranted('ROLE_CUSTOMER_EXPERT')) {
'title' => 'Logs', $menu->addChild('Logs', [
], 'route' => 'app_admin_log',
'extras' => [ 'linkAttributes' => [
'icon' => 'list', 'title' => 'Logs',
'routes' => [['pattern' => '/^app_admin_log/']], ],
], 'extras' => [
]); 'icon' => 'list',
'routes' => [['pattern' => '/^app_admin_log/']],
],
]);
}
$this->addLogoutItem($menu); $this->addLogoutItem($menu);
+6 -12
View File
@@ -14,7 +14,7 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/** /**
* Tells the administrators that an account is waiting for a role to be approved. * Tells the configured recipients that an account is waiting for a role to be approved.
* *
* Runs off the request: mail is routed sync in this application, so sending it inline would put * Runs off the request: mail is routed sync in this application, so sending it inline would put
* SMTP latency and SMTP failures into the login path. * SMTP latency and SMTP failures into the login path.
@@ -22,11 +22,15 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsMessageHandler] #[AsMessageHandler]
final class RoleNominationHandler final class RoleNominationHandler
{ {
/**
* @param string[] $notificationRecipients
*/
public function __construct( public function __construct(
private readonly UserRepository $userRepository, private readonly UserRepository $userRepository,
private readonly Mailer $mailer, private readonly Mailer $mailer,
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator, private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
private readonly LoggerInterface $authLogger, private readonly LoggerInterface $authLogger,
private readonly array $notificationRecipients,
) { ) {
} }
@@ -43,19 +47,9 @@ final class RoleNominationHandler
return; return;
} }
$recipients = array_values(array_filter(array_map( $recipients = $this->notificationRecipients;
static fn ($admin): ?string => $admin->getEmail(),
$this->userRepository->findAdministrators(),
)));
if ([] === $recipients) { if ([] === $recipients) {
// Worth a warning rather than a silent return: nobody can approve the nomination, and
// without this line nobody would find out that the notification goes nowhere.
$this->authLogger->warning('No administrator to notify about a role nomination', [
'userId' => $message->userId,
'roles' => $message->roles,
]);
return; return;
} }
@@ -14,10 +14,17 @@ final readonly class BookingEditSubmissionResult
public const string STATUS_TIMEOUT = 'timeout'; public const string STATUS_TIMEOUT = 'timeout';
public const string STATUS_API_CLIENT_ERROR = 'api_client_error'; public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
/**
* @param list<string> $unbookableServicesReverted Labels of services dropped because they are
* no longer bookable, reported separately from
* the mutability revert: the category is still
* mutable, the service is not bookable
*/
public function __construct( public function __construct(
public string $status, public string $status,
public ?string $message = null, public ?string $message = null,
public bool $immutableChangesReverted = false, public bool $immutableChangesReverted = false,
public array $unbookableServicesReverted = [],
) { ) {
} }
} }
@@ -123,4 +123,35 @@ class BookingEditDraftRepository extends ServiceEntityRepository
->getQuery() ->getQuery()
->execute(); ->execute();
} }
/**
* Finds drafts whose owner has been unable to save for a while.
*
* A draft is deleted the moment an update succeeds (see BookingEditSubmitter), so its mere age
* is the signal: one that has survived for days belongs to somebody whose changes BusPro keeps
* refusing. Drafts for departed travels are excluded - nothing can be done about those, and the
* nightly cleanup removes them anyway.
*
* Ordered by the most recent attempt rather than by age: somebody who tried again yesterday is
* still stuck and still waiting, while the oldest drafts are mostly edits abandoned months ago.
* Sorting by age alone fills the list with the latter and buries the people to help.
*
* @param int $minAgeDays How long a draft must have existed to count as stuck
*
* @return BookingEditDraft[] Most recently attempted first
*/
public function findStuck(int $minAgeDays = 7, int $limit = 10): array
{
return $this->createQueryBuilder('d')
->join('d.user', 'u')
->addSelect('u')
->where('d.createdAt < :cutoff')
->andWhere('d.travelDate >= :today')
->setParameter('cutoff', new \DateTimeImmutable(sprintf('-%d days', $minAgeDays)))
->setParameter('today', new \DateTimeImmutable('today'))
->orderBy('d.updatedAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
} }
-26
View File
@@ -71,32 +71,6 @@ class UserRepository extends ServiceEntityRepository
->getResult(); ->getResult();
} }
/**
* The administrators who can approve a role nomination.
*
* The quote-anchored needle is load-bearing. ROLE_ADMIN_PENDING lives in the same JSON column,
* and an unanchored '%ROLE_ADMIN%' would match it — which would mail the very people whose own
* nomination is unapproved about other people's nominations. The result is filtered through
* Role::effectiveOnly() as well, so the guarantee does not rest on the LIKE alone.
*
* @return User[]
*/
public function findAdministrators(): array
{
/** @var User[] $candidates */
$candidates = $this->createQueryBuilder('u')
->andWhere('u.roles LIKE :admin')
->setParameter('admin', '%"'.Role::ADMIN.'"%')
->orderBy('u.email', 'ASC')
->getQuery()
->getResult();
return array_values(array_filter(
$candidates,
static fn (User $user): bool => \in_array(Role::ADMIN, Role::effectiveOnly($user->getRoles()), true),
));
}
/** /**
* Everyone worth filtering an accommodation booking by: current groups staff, plus whoever * Everyone worth filtering an accommodation booking by: current groups staff, plus whoever
* a booking is still assigned to even after losing the role — otherwise a booking assigned * a booking is still assigned to even after losing the role — otherwise a booking assigned
+15 -6
View File
@@ -67,7 +67,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
public function authenticate(Request $request): Passport public function authenticate(Request $request): Passport
{ {
$email = trim($request->request->getString('_username')); // One canonical casing per account. BusPro matches an address case-insensitively and so
// does the utf8mb4_unicode_ci column, so the casing somebody happens to type must not
// become the casing every later BusPro call re-sends.
$email = mb_strtolower(trim($request->request->getString('_username')));
$passwordPlain = trim($request->request->getString('_password')); $passwordPlain = trim($request->request->getString('_password'));
// BPN requires md5, not a real hash // BPN requires md5, not a real hash
@@ -114,6 +117,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
$user = $userRepository->findOneBy(['email' => $email]) ?? new User($email); $user = $userRepository->findOneBy(['email' => $email]) ?? new User($email);
$user $user
// Not redundant next to the constructor: an account created before the address was
// normalized still carries the casing of its very first login, and everything
// downstream re-sends what is stored rather than what was just typed.
->setEmail($email)
->setPassword($encryptedPassword) ->setPassword($encryptedPassword)
->setPersonId($personalData->personId) ->setPersonId($personalData->personId)
->setAddressId($personalData->addressId) ->setAddressId($personalData->addressId)
@@ -182,9 +189,11 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes))) ->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
; ;
// Compared on the full role sets rather than on the markers alone: nominatedFrom() needs
// to see ROLE_EMPLOYEE to know whether an EMPLOYEE_ONLY marker counts.
$nominated = array_values(array_diff( $nominated = array_values(array_diff(
Role::pendingOnly($user->getRoles()), array_keys(Role::nominatedFrom($user->getRoles())),
Role::pendingOnly($previousRoles), array_keys(Role::nominatedFrom($previousRoles)),
)); ));
if ([] === $nominated) { if ([] === $nominated) {
@@ -198,9 +207,9 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
'roles' => $nominated, 'roles' => $nominated,
]); ]);
// Only the newly appeared markers reach this point, so a repeat login with a nomination // Only the newly appeared nominations reach this point, so a repeat login with a
// still standing announces nothing. That difference is the whole de-duplication. // nomination still standing announces nothing. That difference is the whole de-duplication.
return array_keys(Role::nominatedFrom($nominated)); return $nominated;
} }
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
+52 -7
View File
@@ -23,6 +23,10 @@ namespace App\Security;
* That widens a claim from "what the CRM reports" to "what the CRM reports plus what the account * That widens a claim from "what the CRM reports" to "what the CRM reports plus what the account
* itself implies", and nothing more: ROLE_EMPLOYEE is not administrative, so it cannot reach the * itself implies", and nothing more: ROLE_EMPLOYEE is not administrative, so it cannot reach the
* nomination path, and the CRM remains the only source for every role that grants privileges. * nomination path, and the CRM remains the only source for every role that grants privileges.
*
* ROLE_EMPLOYEE does gate a few administrative roles, though (see EMPLOYEE_ONLY): for those, a
* CRM claim only counts when the account is staff. It narrows what the CRM can nominate and
* never widens it — the approval step still applies on top.
*/ */
final class Role final class Role
{ {
@@ -33,12 +37,14 @@ final class Role
public const USER = 'ROLE_USER'; public const USER = 'ROLE_USER';
public const ADMIN = 'ROLE_ADMIN'; public const ADMIN = 'ROLE_ADMIN';
public const TEAM_ADMIN = 'ROLE_TEAM_ADMIN';
public const MANAGER = 'ROLE_MANAGER'; public const MANAGER = 'ROLE_MANAGER';
public const TEAMER = 'ROLE_TEAMER'; public const TEAMER = 'ROLE_TEAMER';
public const CUSTOMER = 'ROLE_CUSTOMER'; public const CUSTOMER = 'ROLE_CUSTOMER';
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER'; public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN'; public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER'; public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
public const CUSTOMER_EXPERT = 'ROLE_CUSTOMER_EXPERT';
public const EMPLOYEE = 'ROLE_EMPLOYEE'; public const EMPLOYEE = 'ROLE_EMPLOYEE';
/** /**
@@ -57,12 +63,14 @@ final class Role
*/ */
public const ALL = [ public const ALL = [
self::ADMIN, self::ADMIN,
self::TEAM_ADMIN,
self::MANAGER, self::MANAGER,
self::TEAMER, self::TEAMER,
self::CUSTOMER, self::CUSTOMER,
self::HOUSE_MANAGER, self::HOUSE_MANAGER,
self::GROUPS_ADMIN, self::GROUPS_ADMIN,
self::GROUPS_MANAGER, self::GROUPS_MANAGER,
self::CUSTOMER_EXPERT,
self::EMPLOYEE, self::EMPLOYEE,
]; ];
@@ -88,10 +96,31 @@ final class Role
*/ */
public const ADMINISTRATIVE = [ public const ADMINISTRATIVE = [
self::ADMIN, self::ADMIN,
self::TEAM_ADMIN,
self::MANAGER, self::MANAGER,
self::HOUSE_MANAGER, self::HOUSE_MANAGER,
self::GROUPS_ADMIN, self::GROUPS_ADMIN,
self::GROUPS_MANAGER, self::GROUPS_MANAGER,
self::CUSTOMER_EXPERT,
];
/**
* Administrative roles reserved for staff accounts. A CRM claim for one of these is ignored
* unless the account is also claimed as ROLE_EMPLOYEE, so it neither nominates nor keeps a
* role approved earlier. BusPro backend users outside the company can edit their own CRM
* selections too, and these roles reach far enough that the email domain has to agree.
* ROLE_HOUSE_MANAGER is the one administrative role left out: a Hausleitung signs in with
* the hotel's own address.
*
* @var string[]
*/
public const EMPLOYEE_ONLY = [
self::ADMIN,
self::TEAM_ADMIN,
self::MANAGER,
self::GROUPS_ADMIN,
self::GROUPS_MANAGER,
self::CUSTOMER_EXPERT,
]; ];
/** /**
@@ -156,7 +185,9 @@ final class Role
} }
/** /**
* The roles behind those markers, labelled — what an approver acts on. * The roles behind those markers, labelled — what an approver acts on. A marker for an
* EMPLOYEE_ONLY role on an account that is not staff is left out: it predates the rule and
* goes on that account's next login, and until then it must not be approvable.
* *
* @param string[] $roles * @param string[] $roles
* *
@@ -167,9 +198,15 @@ final class Role
$labels = self::labels(); $labels = self::labels();
$nominated = []; $nominated = [];
$isEmployee = \in_array(self::EMPLOYEE, $roles, true);
foreach (self::pendingOnly($roles) as $marker) { foreach (self::pendingOnly($roles) as $marker) {
$role = self::realRole($marker); $role = self::realRole($marker);
if (false === $isEmployee && \in_array($role, self::EMPLOYEE_ONLY, true)) {
continue;
}
if (\in_array($role, self::ADMINISTRATIVE, true)) { if (\in_array($role, self::ADMINISTRATIVE, true)) {
$nominated[$role] = $labels[$role]; $nominated[$role] = $labels[$role];
} }
@@ -181,6 +218,8 @@ final class Role
/** /**
* The whole policy, applied on every login. * The whole policy, applied on every login.
* *
* 0. drop the claims for EMPLOYEE_ONLY roles unless ROLE_EMPLOYEE is claimed as well — they
* then count as not made, so the steps below revoke and never mark them;
* 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is * 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is
* what makes BusPro the source of truth; * what makes BusPro the source of truth;
* 2. grant the unconditional roles it claims; * 2. grant the unconditional roles it claims;
@@ -201,6 +240,10 @@ final class Role
{ {
$claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles))); $claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles)));
if (false === \in_array(self::EMPLOYEE, $claimed, true)) {
$claimed = array_values(array_diff($claimed, self::EMPLOYEE_ONLY));
}
$roles = array_values(array_filter( $roles = array_values(array_filter(
self::assignedOnly($storedRoles), self::assignedOnly($storedRoles),
static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true), static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true),
@@ -221,8 +264,8 @@ final class Role
/** /**
* Turns a marker into the role it stands for. Refuses anything the account is not nominated * Turns a marker into the role it stands for. Refuses anything the account is not nominated
* for, so neither a hand-crafted request nor a claim revoked while the confirmation dialog * for (see nominatedFrom()), so neither a hand-crafted request nor a claim revoked while the
* was open can grant a role the CRM never reported. * confirmation dialog was open can grant a role the CRM never reported.
* *
* @param string[] $storedRoles * @param string[] $storedRoles
* *
@@ -234,7 +277,7 @@ final class Role
{ {
$roles = self::assignedOnly($storedRoles); $roles = self::assignedOnly($storedRoles);
if (false === \in_array(self::pending($role), $roles, true)) { if (false === \array_key_exists($role, self::nominatedFrom($roles))) {
throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role)); throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role));
} }
@@ -281,12 +324,14 @@ final class Role
{ {
$labels = [ $labels = [
self::ADMIN => 'Administration', self::ADMIN => 'Administration',
self::MANAGER => 'Manager:in', self::TEAM_ADMIN => 'Team Administration',
self::MANAGER => 'Reisemanager:in',
self::TEAMER => 'Teamer:in', self::TEAMER => 'Teamer:in',
self::CUSTOMER => 'Kund:in', self::CUSTOMER => 'Kund:in',
self::HOUSE_MANAGER => 'Hausleitung', self::HOUSE_MANAGER => 'Hausleitung',
self::GROUPS_ADMIN => 'Preisrechner Admin', self::GROUPS_ADMIN => 'GRO Admin',
self::GROUPS_MANAGER => 'Preisrechner', self::GROUPS_MANAGER => 'GRO-Expert:in',
self::CUSTOMER_EXPERT => 'KO-Expert:in',
self::EMPLOYEE => 'Mitarbeiter:in', self::EMPLOYEE => 'Mitarbeiter:in',
]; ];
@@ -29,6 +29,7 @@ class AdministrativeAccessVoter extends Voter
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{ {
return $this->accessDecisionManager->decide($token, ['ROLE_ADMIN']) return $this->accessDecisionManager->decide($token, ['ROLE_ADMIN'])
|| $this->accessDecisionManager->decide($token, ['ROLE_GROUPS_MANAGER']); || $this->accessDecisionManager->decide($token, ['ROLE_GROUPS_MANAGER'])
|| $this->accessDecisionManager->decide($token, ['ROLE_CUSTOMER_EXPERT']);
} }
} }
+62 -10
View File
@@ -14,6 +14,7 @@ use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingContext;
use App\Model\AccommodationBookingQueryParams; use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData; use App\Model\CmsHotelData;
use App\Model\InquiryStatus; use App\Model\InquiryStatus;
@@ -23,6 +24,7 @@ use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository; use App\Repository\Groups\BoardServiceRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class AccommodationBookingService class AccommodationBookingService
{ {
@@ -41,6 +43,7 @@ class AccommodationBookingService
private readonly AccommodationBookingLinkSigner $linkSigner, private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator, private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingPdfGenerator $pdfGenerator, private readonly AccommodationBookingPdfGenerator $pdfGenerator,
private readonly ValidatorInterface $validator,
private readonly string $accommodationEmail, private readonly string $accommodationEmail,
) { ) {
} }
@@ -115,6 +118,19 @@ class AccommodationBookingService
return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode()); return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode());
} }
/**
* What the customer-facing offer pages show around a stored booking: the hotel's CMS
* content and the price breakdown computed from the booking's frozen snapshot.
*/
public function createOfferContext(AccommodationBooking $booking, Accommodation $accommodation): AccommodationBookingContext
{
return new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->loadHotelCmsData($accommodation),
priceBreakdown: $this->breakdownCalculator->compute($booking),
);
}
/** /**
* @return AccommodationPrice[] * @return AccommodationPrice[]
*/ */
@@ -262,7 +278,6 @@ class AccommodationBookingService
): AccommodationBooking { ): AccommodationBooking {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setAccommodation($accommodation); $booking->setAccommodation($accommodation);
$booking->setGroupName($dto->groupName);
$booking->setDateFrom($dto->dateFrom); $booking->setDateFrom($dto->dateFrom);
$booking->setDateTo($dto->dateTo); $booking->setDateTo($dto->dateTo);
$booking->setPaxCount($dto->paxCount); $booking->setPaxCount($dto->paxCount);
@@ -305,15 +320,7 @@ class AccommodationBookingService
); );
} }
// Personal data $this->copyContactData($dto, $booking);
$booking->setSalutation($dto->salutation);
$booking->setFirstName($dto->firstName);
$booking->setLastName($dto->lastName);
$booking->setEmail($dto->email);
$booking->setPhone($dto->phone);
$booking->setStreet($dto->street);
$booking->setZip($dto->zip);
$booking->setCity($dto->city);
$booking->setRemarks($dto->remarks); $booking->setRemarks($dto->remarks);
$this->refreshPriceSnapshot($booking); $this->refreshPriceSnapshot($booking);
@@ -627,6 +634,51 @@ class AccommodationBookingService
$this->sendOfferAcceptedNotificationEmail($booking); $this->sendOfferAcceptedNotificationEmail($booking);
} }
/**
* Offers are put together by the office, which only has to supply name and email, so
* the rest of the contact data is often missing. A customer may accept an offer once it
* meets the constraints a self-service booking meets in step 3.
*/
public function hasCompleteContactData(AccommodationBooking $booking): bool
{
return 0 === $this->validator->validate($this->contactDataFromBooking($booking), null, ['contact'])->count();
}
public function contactDataFromBooking(AccommodationBooking $booking): AccommodationBookingDto
{
$dto = new AccommodationBookingDto();
$dto->groupName = $booking->getGroupName();
$dto->salutation = $booking->getSalutation();
$dto->firstName = $booking->getFirstName();
$dto->lastName = $booking->getLastName();
$dto->email = $booking->getEmail();
$dto->phone = $booking->getPhone();
$dto->street = $booking->getStreet();
$dto->zip = $booking->getZip();
$dto->city = $booking->getCity();
return $dto;
}
public function applyContactData(AccommodationBooking $booking, AccommodationBookingDto $dto): void
{
$this->copyContactData($dto, $booking);
$this->entityManager->flush();
}
private function copyContactData(AccommodationBookingDto $dto, AccommodationBooking $booking): void
{
$booking->setGroupName($dto->groupName);
$booking->setSalutation($dto->salutation);
$booking->setFirstName($dto->firstName);
$booking->setLastName($dto->lastName);
$booking->setEmail($dto->email);
$booking->setPhone($dto->phone);
$booking->setStreet($dto->street);
$booking->setZip($dto->zip);
$booking->setCity($dto->city);
}
/** /**
* Explicit office action after the requested services and capacities have been validated: * Explicit office action after the requested services and capacities have been validated:
* this is the moment the booking becomes binding for the customer, and the only place the * this is the moment the booking becomes binding for the customer, and the only place the
+19
View File
@@ -29,6 +29,9 @@ class BookingEditDataLoader
private bool $draftRestored = false; private bool $draftRestored = false;
/** @var list<string> */
private array $droppedDraftServiceLabels = [];
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly BookingDataProcessor $bookingDataProcessor, private readonly BookingDataProcessor $bookingDataProcessor,
@@ -53,6 +56,17 @@ class BookingEditDataLoader
return $this->draftRestored; return $this->draftRestored;
} }
/**
* Returns the drafted services dropped during the last load because they are no longer
* bookable. Reset on each call to loadFormData(), like isDraftRestored().
*
* @return list<string>
*/
public function getDroppedDraftServiceLabels(): array
{
return $this->droppedDraftServiceLabels;
}
/** /**
* Loads booking data from session or initializes from API. * Loads booking data from session or initializes from API.
* *
@@ -72,6 +86,7 @@ class BookingEditDataLoader
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
{ {
$this->draftRestored = false; $this->draftRestored = false;
$this->droppedDraftServiceLabels = [];
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT); $formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
@@ -177,6 +192,10 @@ class BookingEditDataLoader
// it would tell the user their draft was restored for edits they never made. // it would tell the user their draft was restored for edits they never made.
$this->draftRestored = true === $applied $this->draftRestored = true === $applied
&& $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData); && $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData);
if (true === $applied) {
$this->droppedDraftServiceLabels = $this->draftService->getDroppedServiceLabels();
}
} }
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); $this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
+30 -2
View File
@@ -23,6 +23,9 @@ use Psr\Log\LoggerInterface;
*/ */
class BookingEditDraftManager class BookingEditDraftManager
{ {
/** @var list<string> */
private array $droppedServiceLabels = [];
public function __construct( public function __construct(
private readonly BookingEditDraftRepository $draftRepository, private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
@@ -32,6 +35,19 @@ class BookingEditDraftManager
) { ) {
} }
/**
* Returns the services dropped during the last applyDraftToDto() call.
*
* Reset on each call, so the caller can report them to the user. Mirrors
* BookingEditDataLoader::isDraftRestored().
*
* @return list<string>
*/
public function getDroppedServiceLabels(): array
{
return $this->droppedServiceLabels;
}
/** /**
* Finds an existing draft for a user and booking combination. * Finds an existing draft for a user and booking combination.
* *
@@ -129,6 +145,9 @@ class BookingEditDraftManager
*/ */
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
{ {
$this->droppedServiceLabels = [];
$droppedServiceLabels = [];
try { try {
$formData = $draft->getFormData(); $formData = $draft->getFormData();
@@ -149,16 +168,25 @@ class BookingEditDraftManager
continue; continue;
} }
$this->participantApplier->apply( $droppedServiceLabels = [...$droppedServiceLabels, ...$this->participantApplier->apply(
$dto, $dto,
$index, $index,
$dto->participants[$index], $dto->participants[$index],
$participantData, $participantData,
$travel, $travel,
); )];
} }
} }
$this->droppedServiceLabels = array_values(array_unique($droppedServiceLabels));
if ([] !== $this->droppedServiceLabels) {
$this->logger->info('Dropped drafted services that are no longer bookable', [
'booking_id' => $draft->getBookingId(),
'services' => $this->droppedServiceLabels,
]);
}
$this->logger->info('Applied draft to booking DTO', [ $this->logger->info('Applied draft to booking DTO', [
'booking_id' => $draft->getBookingId(), 'booking_id' => $draft->getBookingId(),
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'), 'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
+98 -23
View File
@@ -24,14 +24,24 @@ use App\Form\Model\ParticipantDto;
*/ */
class BookingEditDraftMerger class BookingEditDraftMerger
{ {
/** @param array<string, mixed> $data */ public function __construct(
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
) {
}
/**
* @param array<string, mixed> $data
*
* @return list<string> Labels of drafted services dropped because they are no longer bookable
*/
public function apply( public function apply(
BookingDto $bookingDto, BookingDto $bookingDto,
int $participantIndex, int $participantIndex,
ParticipantDto $participant, ParticipantDto $participant,
array $data, array $data,
Travel $travel, Travel $travel,
): void { ): array {
$droppedServiceLabels = [];
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant); $canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
// Personal data // Personal data
@@ -68,10 +78,19 @@ class BookingEditDraftMerger
// Service selections (gated per-category by travel mutability flags) // Service selections (gated per-category by travel mutability flags)
if (isset($data['services']) && true === is_array($data['services'])) { if (isset($data['services']) && true === is_array($data['services'])) {
$this->applyServiceSelections($participant, $data['services'], $travel); $this->applyServiceSelections(
$participant,
$data['services'],
$travel,
$bookingDto,
$participantIndex,
$droppedServiceLabels
);
} }
$participant->normalizeLoadedData(); $participant->normalizeLoadedData();
return array_values(array_unique($droppedServiceLabels));
} }
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
@@ -205,16 +224,25 @@ class BookingEditDraftMerger
* Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values * Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values
* always replace API data, since users can intentionally clear these selections. * always replace API data, since users can intentionally clear these selections.
*/ */
/** @param array<string, mixed> $data */ /**
private function applyServiceSelections(ParticipantDto $participant, array $data, Travel $travel): void * @param array<string, mixed> $data
{ * @param list<string> $droppedServiceLabels Collects what was dropped, by reference
*/
private function applyServiceSelections(
ParticipantDto $participant,
array $data,
Travel $travel,
BookingDto $bookingDto,
int $participantIndex,
array &$droppedServiceLabels,
): void {
// Additional services category — only apply draft data when services are mutable. // Additional services category — only apply draft data when services are mutable.
// When immutable, the booking's current services must be preserved as-is to avoid // When immutable, the booking's current services must be preserved as-is to avoid
// API rejection (stale draft data could differ from the locked booking state). // API rejection (stale draft data could differ from the locked booking state).
if (true === $travel->additionalServicesMutable) { if (true === $travel->additionalServicesMutable) {
// Ski pass (single service) - merge strategy: only apply if resolves to valid service // Ski pass (single service) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) { if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) {
$resolved = $this->resolveService($data['skiPass'], $travel->additionalServices); $resolved = $this->resolveService($data['skiPass'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->skiPass = $resolved; $participant->skiPass = $resolved;
} }
@@ -222,17 +250,17 @@ class BookingEditDraftMerger
// Courses (array) - overwrite strategy: user can deselect all // Courses (array) - overwrite strategy: user can deselect all
if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) { if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) {
$participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices); $participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
} }
// Board (array) - overwrite strategy: user can deselect all // Board (array) - overwrite strategy: user can deselect all
if (true === array_key_exists('board', $data) && true === is_array($data['board'])) { if (true === array_key_exists('board', $data) && true === is_array($data['board'])) {
$participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices); $participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
} }
// Veg (single service) - merge strategy: only apply if resolves to valid service // Veg (single service) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('veg', $data) && null !== $data['veg']) { if (true === array_key_exists('veg', $data) && null !== $data['veg']) {
$resolved = $this->resolveService($data['veg'], $travel->additionalServices); $resolved = $this->resolveService($data['veg'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->veg = $resolved; $participant->veg = $resolved;
} }
@@ -240,12 +268,12 @@ class BookingEditDraftMerger
// Rentals (array) - overwrite strategy: user can deselect all // Rentals (array) - overwrite strategy: user can deselect all
if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) { if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) {
$participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices); $participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
} }
// Rental insurance (single) - merge strategy: only apply if resolves to valid service // Rental insurance (single) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) { if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) {
$resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices); $resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->rentalInsurance = $resolved; $participant->rentalInsurance = $resolved;
$participant->rentalInsuranceSelected = true; $participant->rentalInsuranceSelected = true;
@@ -255,7 +283,7 @@ class BookingEditDraftMerger
// Additional services (array) - overwrite strategy with mandatory service preservation // Additional services (array) - overwrite strategy with mandatory service preservation
// User can deselect optional services, but mandatory services from API must be preserved // User can deselect optional services, but mandatory services from API must be preserved
if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) { if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) {
$resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices); $resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
$participant->additionalServices = $this->preserveMandatoryServices( $participant->additionalServices = $this->preserveMandatoryServices(
$resolvedFromDraft, $resolvedFromDraft,
$participant->additionalServices, $participant->additionalServices,
@@ -268,7 +296,7 @@ class BookingEditDraftMerger
if (true === $travel->transportationServicesMutable) { if (true === $travel->transportationServicesMutable) {
// Transportation outbound (single) - merge strategy: only apply if resolves to valid service // Transportation outbound (single) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) { if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) {
$resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices); $resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->transportationOutbound = $resolved; $participant->transportationOutbound = $resolved;
} }
@@ -276,7 +304,7 @@ class BookingEditDraftMerger
// Transportation inbound (single) - merge strategy: only apply if resolves to valid service // Transportation inbound (single) - merge strategy: only apply if resolves to valid service
if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) { if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) {
$resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices); $resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
if (null !== $resolved) { if (null !== $resolved) {
$participant->transportationInbound = $resolved; $participant->transportationInbound = $resolved;
} }
@@ -368,37 +396,84 @@ class BookingEditDraftMerger
/** /**
* @param array<int, object> $services * @param array<int, object> $services
* @param list<string> $droppedServiceLabels
*/ */
private function resolveService(?int $serviceId, array $services): ?object private function resolveService(
{ ?int $serviceId,
array $services,
BookingDto $bookingDto,
int $participantIndex,
array &$droppedServiceLabels,
): ?object {
if (null === $serviceId) { if (null === $serviceId) {
return null; return null;
} }
return $services[$serviceId] ?? null; $service = $services[$serviceId] ?? null;
if (null !== $service && true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
$droppedServiceLabels[] = (string) $service->label;
return null;
}
return $service;
} }
/** /**
* @param array<int, int> $serviceIds * @param array<int, int> $serviceIds
* @param array<int, object> $services * @param array<int, object> $services
* @param list<string> $droppedServiceLabels
* *
* @return array<int, object> * @return array<int, object>
*/ */
private function resolveServiceArray(array $serviceIds, array $services): array private function resolveServiceArray(
{ array $serviceIds,
array $services,
BookingDto $bookingDto,
int $participantIndex,
array &$droppedServiceLabels,
): array {
$resolved = []; $resolved = [];
$addedIds = []; $addedIds = [];
foreach ($serviceIds as $serviceId) { foreach ($serviceIds as $serviceId) {
if (isset($services[$serviceId]) && false === isset($addedIds[$serviceId])) { if (false === isset($services[$serviceId]) || true === isset($addedIds[$serviceId])) {
$resolved[] = $services[$serviceId]; continue;
$addedIds[$serviceId] = true;
} }
$service = $services[$serviceId];
if (true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
$droppedServiceLabels[] = (string) $service->label;
$addedIds[$serviceId] = true;
continue;
}
$resolved[] = $service;
$addedIds[$serviceId] = true;
} }
return $resolved; return $resolved;
} }
/**
* Checks whether a drafted selection must not be restored.
*
* A draft can be weeks old. Restoring a selection for a service that has since sold out or
* moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice is re-applied on
* every re-entry and BusPro rejects the whole update again. Services the participant already
* holds are carved out by the availability rule itself, so they still restore.
*/
private function isDroppedFromDraft(object $service, BookingDto $bookingDto, int $participantIndex): bool
{
if (false === $service instanceof Service || null === $service->id) {
return false;
}
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
}
private function resolvePickup(?int $pickupId, Travel $travel): ?object private function resolvePickup(?int $pickupId, Travel $travel): ?object
{ {
if (null === $pickupId) { if (null === $pickupId) {
+63
View File
@@ -24,9 +24,72 @@ class BookingEditSubmitGuard
{ {
public function __construct( public function __construct(
private readonly BookingDataProcessor $bookingDataProcessor, private readonly BookingDataProcessor $bookingDataProcessor,
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
) { ) {
} }
/**
* Reverts selections of services that are no longer bookable.
*
* The read-only state in the participant form is presentation only, so a stale session or
* a replayed POST can still carry a service that has since sold out or moved to 'Anfrage'.
* Sending one makes BusPro reject the entire update - every participant's changes with it -
* so the selection is reverted to what the live booking already holds.
*
* Services the participant already holds are never touched: the availability rule carves
* them out, and withdrawing one would break the Leistung/Teilnehmer counts.
*
* Expects $workingDto->booking to be the fresh booking, as the availability rule reads its
* already-held carve-out from there.
*
* @return list<string> Labels of the services that were reverted, for user feedback
*/
public function revertUnbookableServiceAdditions(BookingDto $workingDto, Booking $freshBooking): array
{
$baselineDto = $this->bookingDataProcessor->createBookingDtoFromBooking($freshBooking, $workingDto->travel, $workingDto->isInternalAgencyBooking());
$revertedLabels = [];
foreach ($workingDto->participants as $index => $participant) {
$baseline = $baselineDto->participants[$index] ?? null;
if (null === $baseline) {
continue;
}
$isBlocked = fn (?Service $service): bool => null !== $service
&& null !== $service->id
&& $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $workingDto, $index);
// Multi-selection fields: drop the blocked additions, keep everything else
foreach (['courses', 'additionalServices', 'board', 'rentals'] as $field) {
$kept = [];
foreach ($participant->{$field} as $service) {
if (true === $isBlocked($service)) {
$revertedLabels[] = (string) $service->label;
continue;
}
$kept[] = $service;
}
if (count($kept) !== count($participant->{$field})) {
$participant->{$field} = $kept;
}
}
// Single-selection fields: fall back to what the booking already holds rather than
// clearing, so a required field does not end up empty
foreach (['skiPass', 'veg', 'transportationOutbound', 'transportationInbound', 'parkingService'] as $field) {
if (false === $isBlocked($participant->{$field})) {
continue;
}
$revertedLabels[] = (string) $participant->{$field}->label;
$participant->{$field} = $baseline->{$field};
}
}
return array_values(array_unique($revertedLabels));
}
/** /**
* Reverts immutable category changes to fresh booking values. * Reverts immutable category changes to fresh booking values.
* *
+27 -1
View File
@@ -63,11 +63,32 @@ class BookingEditSubmitter
$this->travelDataService->patchMutability($bookingDto->travel, $mutableData); $this->travelDataService->patchMutability($bookingDto->travel, $mutableData);
} }
// The guard checks service status and contingent, so it needs the live figures rather
// than whatever was cached when the edit session started.
$availabilities = $this->travelDataService->getAvailabilityData(
$freshBookingData->dateId,
cached: true,
forceRefresh: true
);
if (null !== $availabilities) {
$this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities);
}
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData); $immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
if (true === $immutableChangesReverted) { $unbookableServicesReverted = $this->submitGuard->revertUnbookableServiceAdditions($bookingDto, $freshBookingData);
if (true === $immutableChangesReverted || [] !== $unbookableServicesReverted) {
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
} }
if ([] !== $unbookableServicesReverted) {
$this->logger->warning('Reverted selections of services that are no longer bookable', [
'email' => $email,
'booking_id' => $bookingId,
'services' => $unbookableServicesReverted,
]);
}
try { try {
$response = $this->apiClient->updateBooking($bookingDto, true); $response = $this->apiClient->updateBooking($bookingDto, true);
if ($response instanceof Notification) { if ($response instanceof Notification) {
@@ -83,6 +104,7 @@ class BookingEditSubmitter
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO, : BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
$response->message, $response->message,
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} }
@@ -99,6 +121,7 @@ class BookingEditSubmitter
BookingEditSubmissionResult::STATUS_SUCCESS, BookingEditSubmissionResult::STATUS_SUCCESS,
null, null,
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} }
@@ -113,6 +136,7 @@ class BookingEditSubmitter
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL, BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
$response->status ?? 'Buchung konnte nicht aktualisiert werden', $response->status ?? 'Buchung konnte nicht aktualisiert werden',
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} catch (TimeoutException) { } catch (TimeoutException) {
$this->logger->error('Booking update timeout', [ $this->logger->error('Booking update timeout', [
@@ -124,12 +148,14 @@ class BookingEditSubmitter
BookingEditSubmissionResult::STATUS_TIMEOUT, BookingEditSubmissionResult::STATUS_TIMEOUT,
null, null,
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} catch (ApiClientException) { } catch (ApiClientException) {
return new BookingEditSubmissionResult( return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR, BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
null, null,
$immutableChangesReverted, $immutableChangesReverted,
$unbookableServicesReverted,
); );
} }
} }
+94 -2
View File
@@ -55,6 +55,13 @@ class ServiceAvailabilityCalculator
* *
* Delegates to isServiceUnavailable() so that both entry points share one rule set. * Delegates to isServiceUnavailable() so that both entry points share one rule set.
* *
* Never empties a non-empty choice group on the on-request rule alone: on some termine
* every outbound transport option or every ski pass is 'Anfrage', and an empty required
* group makes the travel unbookable - ParticipantEligibilityChecker reads "offered but
* none selectable" as the participant being unable to travel. Sold-out and Buchungsstop
* still empty a group, preserving the pre-existing semantics. Inert in create mode, where
* the on-request rule cannot fire.
*
* @param array<int, Service> $services Array of Service objects to filter * @param array<int, Service> $services Array of Service objects to filter
* @param BookingDto $bookingDto The booking data with participant selections * @param BookingDto $bookingDto The booking data with participant selections
* @param int $participantIndex The index of the participant currently filling the form * @param int $participantIndex The index of the participant currently filling the form
@@ -63,7 +70,7 @@ class ServiceAvailabilityCalculator
*/ */
public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array
{ {
return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) { $filtered = array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) {
// Services without an ID cannot be resolved against travel data - treat as available // Services without an ID cannot be resolved against travel data - treat as available
if (null === $service->id) { if (null === $service->id) {
return true; return true;
@@ -71,6 +78,17 @@ class ServiceAvailabilityCalculator
return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex); return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
}); });
if ([] !== $filtered || [] === $services) {
return $filtered;
}
return array_filter(
$services,
fn (Service $service): bool => $this->isBlockedByOnRequestStatus($service, $bookingDto)
&& Constants::STATUS_BLOCKED !== $service->status
&& false === $this->isContingentExhausted($service, $bookingDto, $participantIndex)
);
} }
/** /**
@@ -80,6 +98,16 @@ class ServiceAvailabilityCalculator
* contingent is exhausted, either at the API level or through selections made by the * contingent is exhausted, either at the API level or through selections made by the
* other participants of the current booking. * other participants of the current booking.
* *
* In edit mode one further rule applies: a service that is only available on request
* (Anfrage) cannot be *acquired*. The participants of an existing booking are fixed at
* status 'F', and BusPro derives a Leistung's status from the travel data and refuses to
* attach it when the two differ ("Status der Leistung (A) ist unterschiedlich zum Status
* des Teilnehmers (F)"), rejecting the whole update. The create flow has no such problem,
* because the booking status can still move to 'A' there - hence the mode precondition.
*
* The rule is asymmetric: a service the participant already holds in the live booking
* stays available, so it can be kept and re-sent.
*
* @param int $serviceId The ID of the service to check * @param int $serviceId The ID of the service to check
* @param BookingDto $bookingDto The booking data with participant selections * @param BookingDto $bookingDto The booking data with participant selections
* @param int $participantIndex The index of the participant currently filling the form * @param int $participantIndex The index of the participant currently filling the form
@@ -103,11 +131,32 @@ class ServiceAvailabilityCalculator
return false; return false;
} }
// A service the participant already holds is always keepable, whatever its state now
if (true === $this->isServiceHeldByParticipant($service, $bookingDto, $participantIndex)) {
return false;
}
// A booking stop blocks the service regardless of its contingent // A booking stop blocks the service regardless of its contingent
if (Constants::STATUS_BLOCKED === $service->status) { if (Constants::STATUS_BLOCKED === $service->status) {
return true; return true;
} }
// On request cannot be added to a participant whose status is already fixed
if (true === $this->isBlockedByOnRequestStatus($service, $bookingDto)) {
return true;
}
return $this->isContingentExhausted($service, $bookingDto, $participantIndex);
}
/**
* Checks whether a service has no contingent left.
*
* Covers both the API-level figure and the seats taken by the other participants of the
* current booking.
*/
private function isContingentExhausted(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{
// If no availability tracking (null), service is unlimited and available // If no availability tracking (null), service is unlimited and available
if (null === $service->available) { if (null === $service->available) {
return false; return false;
@@ -121,7 +170,50 @@ class ServiceAvailabilityCalculator
// For services with positive availability, calculate remaining based on booking selections // For services with positive availability, calculate remaining based on booking selections
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex); $remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
return ($remainingAvailability[$serviceId] ?? $service->available) <= 0; return ($remainingAvailability[$service->id] ?? $service->available) <= 0;
}
/**
* Checks whether the on-request rule blocks a service.
*
* Only applies in edit mode, and never to services the form would then be unable to
* satisfy: auto-booked services and mandatory services outside the transport categories
* are required by MandatoryAdditionalServicesSelectedValidator, so blocking one makes the
* form unsatisfiable. Mandatory transport legs are deliberately not exempt - there
* pflicht="True" means "pick one of this group" rather than "compulsory", and exempting
* them would exempt the bus legs this rule exists for.
*/
private function isBlockedByOnRequestStatus(Service $service, BookingDto $bookingDto): bool
{
if (BookingDto::MODE_EDIT !== $bookingDto->getMode()) {
return false;
}
if (Constants::STATUS_ON_REQUEST !== $service->status) {
return false;
}
if (true === $service->autoBook) {
return false;
}
return false === ($service->mandatory && Constants::CATEGORY_TRANSPORTATION !== $service->category);
}
/**
* Checks whether the participant already holds the service in the live booking.
*
* Reads the BusPro-side baseline (BookingDto::$booking), never the working selection,
* which already carries whatever the customer just picked. Always false in create mode,
* where there is no booking yet.
*/
private function isServiceHeldByParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
{
if (null === $bookingDto->booking || null === $service->id) {
return false;
}
return $bookingDto->booking->hasServiceForParticipant($participantIndex, $service->id);
} }
/** /**
+90
View File
@@ -0,0 +1,90 @@
{% extends 'layout_booking.html.twig' %}
{% block title %}Kontaktdaten vervollständigen{% endblock %}
{% block background %}bg-outer bg-outer--summer{% endblock %}
{% block content %}
{% form_theme form 'booking/_form_theme.html.twig' %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate',
}
}) }}
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Sidebar summary #}
<div class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
{% include 'groups/offer/_summary.html.twig' with {
booking: booking,
ctx: ctx,
currency: currency,
} %}
</div>
{# Contact data form #}
<div class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{% include '_partials/_flashes.html.twig' %}
<div class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8">
<hgroup class="pb-4">
<h1 class="text-2xl lg:text-4xl font-bold mb-2">
Kontaktdaten vervollständigen
</h1>
<p>
Bevor du das Angebot für {{ ctx.accommodation.name }} buchen kannst, benötigen wir
noch deine vollständigen Kontaktdaten.
</p>
</hgroup>
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
{{ validation_alert(form, 'Bitte fülle alle Pflichtfelder aus.') }}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.groupName) }}
{{ form_row(form.salutation) }}
</div>
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
</div>
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.email) }}
{{ form_row(form.phone) }}
</div>
<h3 class="pt-4 pb-2">Adresse</h3>
{{ form_row(form.street) }}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.zip) }}
{{ form_row(form.city) }}
</div>
</div>
</div>
</div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex items-center justify-between">
<a href="{{ path('app_groups_offer_view', { uuid: booking.uuid }) }}" class="button button--secondary">
Zurück zum Angebot
</a>
<button type="submit" class="button button--primary">
Speichern
</button>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+52 -2
View File
@@ -6,6 +6,7 @@ namespace App\Tests\BusProNet;
use App\BusProNet\ApiClient; use App\BusProNet\ApiClient;
use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException; use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\XmlParser\ApiResponseParser; use App\BusProNet\XmlParser\ApiResponseParser;
use App\Service\RequestIdGenerator; use App\Service\RequestIdGenerator;
@@ -70,7 +71,7 @@ class ApiClientReceiveTest extends TestCase
$this->invokeReceive($stream); $this->invokeReceive($stream);
} }
public function testNonEmptyResponseReturnsData(): void public function testNonEmptyResponseReturnsPayloadWithoutTheLengthHeader(): void
{ {
$stream = fopen('php://memory', 'r+'); $stream = fopen('php://memory', 'r+');
fwrite($stream, '0000000005Hello'); fwrite($stream, '0000000005Hello');
@@ -80,7 +81,56 @@ class ApiClientReceiveTest extends TestCase
$result = $this->invokeReceive($stream); $result = $this->invokeReceive($stream);
self::assertSame('0000000005Hello', $result); self::assertSame('Hello', $result);
}
/**
* A peer closing mid-stream used to yield a silently shortened payload, which only
* surfaced later as an unexplained parse failure. The announced length catches it here.
*/
public function testTruncatedResponseThrows(): void
{
$stream = fopen('php://memory', 'r+');
fwrite($stream, '0000000050Hello');
rewind($stream);
$this->setOperationStartTime();
$this->setSelectedPort(9000);
$this->expectException(ApiClientException::class);
$this->expectExceptionMessage('Truncated response, got 5 of 50 announced bytes');
$this->invokeReceive($stream);
}
public function testResponseShorterThanTheLengthHeaderThrows(): void
{
$stream = fopen('php://memory', 'r+');
fwrite($stream, '00000');
rewind($stream);
$this->setOperationStartTime();
$this->setSelectedPort(9000);
$this->expectException(ApiClientException::class);
$this->expectExceptionMessage('Incomplete response header, got 5 of 10 bytes');
$this->invokeReceive($stream);
}
public function testHeaderOnlyResponseThrows(): void
{
$stream = fopen('php://memory', 'r+');
fwrite($stream, '0000000000');
rewind($stream);
$this->setOperationStartTime();
$this->setSelectedPort(9000);
$this->expectException(ApiClientException::class);
$this->expectExceptionMessage('Response announced an empty body (header "0000000000")');
$this->invokeReceive($stream);
} }
private function invokeReceive($socket): string private function invokeReceive($socket): string
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlLoader;
use App\BusProNet\Constants;
use App\BusProNet\Model\Availability;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\BusProNet\XmlParser\TravelParser;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Cache\CacheInterface;
/**
* Tests that patchAvailabilities() carries the live service status, not just the contingent.
*
* When a Leistung's contingent runs out BusPro moves it to 'Anfrage' without necessarily
* reporting frei="0", so the status is the only reliable signal. Dropping it meant every
* availability rule read the status from the nightly XML export instead.
*/
class TravelLoaderAvailabilityTest extends TestCase
{
private TravelLoader $loader;
protected function setUp(): void
{
$this->loader = new TravelLoader(
$this->createStub(HotelLoader::class),
$this->createStub(TravelParser::class),
'https://example.test',
$this->createStub(CacheInterface::class),
$this->createStub(FilesystemOperator::class),
);
}
public function testPatchesStatusAlongsideAvailability(): void
{
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
$travel = $this->createTravel([$service]);
$this->loader->patchAvailabilities(
$travel,
$this->createResponse([$this->createAvailability(60, Constants::STATUS_ON_REQUEST, 3)])
);
$this->assertSame(Constants::STATUS_ON_REQUEST, $service->status);
$this->assertSame(3, $service->available);
}
public function testKeepsExportStatusWhenTheResponseOmitsIt(): void
{
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
$travel = $this->createTravel([$service]);
$this->loader->patchAvailabilities(
$travel,
$this->createResponse([$this->createAvailability(60, null, 3)])
);
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
$this->assertSame(3, $service->available);
}
public function testKeepsExportStatusWhenTheResponseCarriesABlankOne(): void
{
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
$travel = $this->createTravel([$service]);
$this->loader->patchAvailabilities(
$travel,
$this->createResponse([$this->createAvailability(60, ' ', 3)])
);
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
}
public function testPatchesTransportationServicesToo(): void
{
$service = $this->createService(80, Constants::STATUS_AVAILABLE, 10);
$travel = new Travel();
$travel->transportationServices = [80 => $service];
$this->loader->patchAvailabilities(
$travel,
$this->createResponse([$this->createAvailability(80, Constants::STATUS_BLOCKED, 0)])
);
$this->assertSame(Constants::STATUS_BLOCKED, $service->status);
$this->assertSame(0, $service->available);
}
/** @param array<int, Service> $services */
private function createTravel(array $services): Travel
{
$travel = new Travel();
$indexed = [];
foreach ($services as $service) {
$indexed[$service->id] = $service;
}
$travel->additionalServices = $indexed;
return $travel;
}
private function createService(int $id, string $status, ?int $available): Service
{
$service = new Service();
$service->id = $id;
$service->status = $status;
$service->available = $available;
return $service;
}
private function createAvailability(int $serviceId, ?string $status, ?int $available): Availability
{
$availability = new Availability();
$availability->serviceId = $serviceId;
$availability->status = $status;
$availability->available = $available;
return $availability;
}
/** @param array<int, Availability> $availabilities */
private function createResponse(array $availabilities): ServiceAvailabilityResponse
{
$indexed = [];
foreach ($availabilities as $availability) {
$indexed[$availability->serviceId] = $availability;
}
return new ServiceAvailabilityResponse($indexed);
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\XmlParser\ApiResponseParser;
use PHPUnit\Framework\TestCase;
/**
* A broken response used to collapse into either "Empty response received from server" —
* which is wrong for a malformed body — or an opaque "Unable to parse XML response".
* Each shape must now name itself, otherwise a production parse failure is not
* diagnosable after the fact.
*/
class ApiResponseParserFailureTest extends TestCase
{
public function testEmptyResponse(): void
{
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('empty response (0 bytes)');
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, '');
}
public function testTruncatedResponseNamesTheLibxmlReason(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="HINWEIS"></satz><hinweis';
try {
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
$this->fail('Expected a ResponseParserException');
} catch (ResponseParserException $e) {
self::assertStringContainsString('('.strlen($xml).' bytes)', $e->getMessage());
self::assertStringContainsString('line 1', $e->getMessage());
}
}
public function testWellFormedResponseOfAnUnknownType(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="IRGENDWAS"></satz></ergebnis>';
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised BusProNet response type "IRGENDWAS"');
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
}
public function testUnknownCustomerDataSubtype(): void
{
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Irgendwas</art></ergebnis>';
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised customer data subtype "Irgendwas"');
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $xml);
}
public function testDiagnosingDoesNotLeakLibxmlErrorState(): void
{
$previous = libxml_use_internal_errors(false);
try {
try {
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, '<ergebnis');
} catch (ResponseParserException) {
}
self::assertFalse(libxml_use_internal_errors(false));
self::assertSame([], libxml_get_errors());
} finally {
libxml_use_internal_errors($previous);
}
}
}
@@ -40,6 +40,20 @@ class CrmAttributesResponseParserTest extends TestCase
self::assertNotContains('ROLE_GROUPS_MANAGER', $roles); self::assertNotContains('ROLE_GROUPS_MANAGER', $roles);
} }
public function testParseAssignsCustomerExpertRoleWhenSelected(): void
{
$roles = $this->parseRoles($this->selectionXml(1483, true));
self::assertContains('ROLE_CUSTOMER_EXPERT', $roles);
}
public function testParseAssignsNoCustomerExpertRoleWhenNotSelected(): void
{
$roles = $this->parseRoles($this->selectionXml(1483, false));
self::assertNotContains('ROLE_CUSTOMER_EXPERT', $roles);
}
public function testParseAssignsNoGroupsRolesWhenNotSelected(): void public function testParseAssignsNoGroupsRolesWhenNotSelected(): void
{ {
$roles = $this->parseRoles($this->selectionXml(1477, false)); $roles = $this->parseRoles($this->selectionXml(1477, false));
@@ -33,11 +33,11 @@ class ApproveRoleControllerTest extends TestCase
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class)); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN')); $response = $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView); self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView);
self::assertSame([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)], Role::assignedOnly($user->getRoles())); self::assertSame([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)], Role::assignedOnly($user->getRoles()));
} }
public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void
@@ -49,9 +49,9 @@ class ApproveRoleControllerTest extends TestCase
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class)); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $response = $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles())); self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], Role::assignedOnly($user->getRoles()));
self::assertTrue($response->headers->has('HX-Redirect')); self::assertTrue($response->headers->has('HX-Redirect'));
self::assertSame(['success'], array_column($controller->flashes, 'type')); self::assertSame(['success'], array_column($controller->flashes, 'type'));
} }
@@ -94,9 +94,9 @@ class ApproveRoleControllerTest extends TestCase
// Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the // Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the
// rest grant less than they could grant themselves anyway. // rest grant less than they could grant themselves anyway.
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles())); self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], Role::assignedOnly($user->getRoles()));
} }
public function testPostWithAnInvalidTokenIsDenied(): void public function testPostWithAnInvalidTokenIsDenied(): void
@@ -108,7 +108,7 @@ class ApproveRoleControllerTest extends TestCase
$this->expectException(AccessDeniedException::class); $this->expectException(AccessDeniedException::class);
$controller->index($this->nominatedUser(), Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $controller->index($this->nominatedUser(), Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
} }
public function testApprovingForYourselfReissuesTheSecurityToken(): void public function testApprovingForYourselfReissuesTheSecurityToken(): void
@@ -125,12 +125,12 @@ class ApproveRoleControllerTest extends TestCase
tokenStorage: $tokenStorage, tokenStorage: $tokenStorage,
); );
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
// Without this the next request would find the stored roles out of step with the token // Without this the next request would find the stored roles out of step with the token
// and end the session, logging the approver out mid-action. // and end the session, logging the approver out mid-action.
self::assertContains(Role::GROUPS_ADMIN, $tokenStorage->getToken()?->getRoleNames() ?? []); self::assertContains(Role::HOUSE_MANAGER, $tokenStorage->getToken()?->getRoleNames() ?? []);
self::assertNotContains(Role::pending(Role::GROUPS_ADMIN), $tokenStorage->getToken()?->getRoleNames() ?? []); self::assertNotContains(Role::pending(Role::HOUSE_MANAGER), $tokenStorage->getToken()?->getRoleNames() ?? []);
} }
public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void
@@ -148,14 +148,14 @@ class ApproveRoleControllerTest extends TestCase
tokenStorage: $tokenStorage, tokenStorage: $tokenStorage,
); );
$controller->index($other, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); $controller->index($other, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
self::assertSame($originalToken, $tokenStorage->getToken()); self::assertSame($originalToken, $tokenStorage->getToken());
} }
private function nominatedUser(): User private function nominatedUser(): User
{ {
return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)]); return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)]);
} }
} }
@@ -21,7 +21,7 @@ class ShowControllerTest extends TestCase
$controller->index($this->nominatedUser(), $this->permissionsRequest()); $controller->index($this->nominatedUser(), $this->permissionsRequest());
self::assertSame( self::assertSame(
[Role::MANAGER => 'Manager:in', Role::GROUPS_ADMIN => 'Preisrechner Admin'], [Role::HOUSE_MANAGER => 'Hausleitung', Role::GROUPS_ADMIN => 'GRO Admin'],
$controller->parameters['approvableRoles'], $controller->parameters['approvableRoles'],
); );
self::assertSame([], $controller->parameters['selfRefusedRoles']); self::assertSame([], $controller->parameters['selfRefusedRoles']);
@@ -29,14 +29,14 @@ class ShowControllerTest extends TestCase
public function testWhatCannotBeSelfApprovedIsNotOffered(): void public function testWhatCannotBeSelfApprovedIsNotOffered(): void
{ {
$user = (new User('admin@example.org'))->setRoles([Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]); $user = (new User('admin@ep-reisen.de'))->setRoles([Role::EMPLOYEE, Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]);
$controller = new TestableShowController(currentUser: $user); $controller = new TestableShowController(currentUser: $user);
$controller->index($user, $this->permissionsRequest()); $controller->index($user, $this->permissionsRequest());
// ROLE_ADMIN needs a second administrator, so no button leads into an access denied page. // ROLE_ADMIN needs a second administrator, so no button leads into an access denied page.
self::assertSame([Role::MANAGER => 'Manager:in'], $controller->parameters['approvableRoles']); self::assertSame([Role::MANAGER => 'Reisemanager:in'], $controller->parameters['approvableRoles']);
self::assertSame([Role::ADMIN => 'Administration'], $controller->parameters['selfRefusedRoles']); self::assertSame([Role::ADMIN => 'Administration'], $controller->parameters['selfRefusedRoles']);
} }
@@ -62,8 +62,8 @@ class ShowControllerTest extends TestCase
private function nominatedUser(): User private function nominatedUser(): User
{ {
return (new User('teamer@example.org')) return (new User('teamer@ep-reisen.de'))
->setRoles([Role::TEAMER, Role::pending(Role::MANAGER), Role::pending(Role::GROUPS_ADMIN)]); ->setRoles([Role::TEAMER, Role::EMPLOYEE, Role::pending(Role::HOUSE_MANAGER), Role::pending(Role::GROUPS_ADMIN)]);
} }
/** /**
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\PersonalData;
use App\Controller\Api\UserinfoController;
use App\Entity\User;
use App\Security\Crypt;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* The claims are a published contract (docs/api-consumer-guide.md), and the identity claims in
* particular: a BusPro person may sign in under any address on its record, every one of those is
* a separate account with its own roles, and BusPro answers all of them identically. So what
* separates the accounts is asserted here rather than left to the consumers to discover.
*/
class UserinfoControllerTest extends TestCase
{
private const LOGIN_EMAIL = '[email protected]';
private const CONTACT_EMAIL = '[email protected]';
public function testSubAndEmailAreExportedWithoutAnyOptionalScope(): void
{
$claims = $this->claims();
self::assertSame(['sub', 'email'], array_keys($claims));
self::assertSame('42', $claims['sub']);
self::assertSame(self::LOGIN_EMAIL, $claims['email']);
}
public function testSubIsTheLocalAccountAndNotTheBusProPerson(): void
{
$claims = $this->claims(['ROLE_OAUTH2_ID']);
// The ids identify the human, the subject identifies the account. A second account of the
// same person reports these same two ids and must still be told apart.
self::assertSame('42', $claims['sub']);
self::assertSame(7, $claims['person_id']);
self::assertSame(9, $claims['address_id']);
}
public function testEmailIsTheAuthenticatedAddressAndNotTheFirstContactOnTheBusProRecord(): void
{
$claims = $this->claims(['ROLE_OAUTH2_PROFILE']);
self::assertSame(self::LOGIN_EMAIL, $claims['email']);
self::assertSame(self::CONTACT_EMAIL, $claims['profile']['communication']['email']);
}
public function testRolesExportOnlyWhatIsEffective(): void
{
$claims = $this->claims(['ROLE_OAUTH2_ROLES'], [
Role::USER,
Role::EMPLOYEE,
Role::TEAMER,
Role::pending(Role::MANAGER),
]);
// ROLE_EMPLOYEE is exported because consumers must read staff status from here rather than
// re-deriving it from an email domain — which, given the two addresses, would disagree.
self::assertSame([Role::EMPLOYEE, Role::TEAMER], $claims['roles']);
}
public function testUpstreamFailureIsReportedRatherThanAnswered(): void
{
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('getPersonalData')->willThrowException(new \App\BusProNet\Exception\ApiClientException('nope'));
$response = $this->call($apiClient, []);
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
}
/**
* @param string[] $grantedScopeRoles
* @param string[] $roles
*
* @return array<string, mixed>
*/
private function claims(array $grantedScopeRoles = [], array $roles = [Role::TEAMER]): array
{
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('getPersonalData')->willReturn($this->personalData());
$response = $this->call($apiClient, $grantedScopeRoles, $roles);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
return json_decode((string) $response->getContent(), true);
}
/**
* @param string[] $grantedScopeRoles
* @param string[] $roles
*/
private function call(ApiClient $apiClient, array $grantedScopeRoles, array $roles = [Role::TEAMER]): Response
{
$crypt = $this->createStub(Crypt::class);
$crypt->method('decrypt')->willReturn('md5-of-the-password');
$controller = new UserinfoController($apiClient, $crypt);
$controller->setContainer($this->container($this->user($roles), $grantedScopeRoles));
return $controller->index();
}
/**
* @param string[] $grantedScopeRoles
*/
private function container(User $user, array $grantedScopeRoles): Container
{
$tokenStorage = new TokenStorage();
$tokenStorage->setToken(new UsernamePasswordToken($user, 'api', $user->getRoles()));
$authorizationChecker = $this->createStub(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => \in_array($attribute, $grantedScopeRoles, true),
);
// No 'serializer' service registered, so AbstractController::json() falls back to
// JsonResponse — which is what this endpoint produces in production anyway.
$container = new Container();
$container->set('security.token_storage', $tokenStorage);
$container->set('security.authorization_checker', $authorizationChecker);
return $container;
}
/**
* @param string[] $roles
*/
private function user(array $roles): User
{
$user = (new User(self::LOGIN_EMAIL))
->setPassword('encrypted')
->setRoles($roles)
;
// The id is generated by Doctrine and has no setter, but it is what `sub` exports.
$property = new \ReflectionProperty(User::class, 'id');
$property->setValue($user, 42);
return $user;
}
private function personalData(): PersonalData
{
$personalData = new PersonalData();
$personalData->personId = 7;
$personalData->addressId = 9;
$personalData->firstName = 'Alex';
$personalData->name = 'Beispiel';
// Deliberately not the login address: BusPro returns the first contact row on the record,
// which is whichever of the person's addresses happens to come first.
$personalData->communication->email = self::CONTACT_EMAIL;
return $personalData;
}
}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Groups\Offer;
use App\Controller\Groups\Offer\ConfirmController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\OfferAcceptDto;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ConfirmControllerTest extends TestCase
{
public function testGetRendersModalWithFreshForm(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setRemarks("Bitte Zimmer im EG\nDanke!");
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(false);
$bookingService = $this->serviceWithCompleteContactData();
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferConfirmController(
$bookingRepository,
$this->authorizedLinkSigner(),
$bookingService,
$confirmationForm,
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']);
self::assertInstanceOf(OfferAcceptDto::class, $controller->formData);
self::assertSame("Bitte Zimmer im EG\nDanke!", $controller->formData->remarks, 'the stored remark is prefilled into the modal');
self::assertFalse($controller->formData->termsAccepted);
}
public function testPostValidAcceptsBookingAndRedirects(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setRemarks('Bitte Zimmer im EG');
$bookingService = $this->serviceWithCompleteContactData();
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, 'Bitte Zimmer im EG');
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$bookingService,
$this->submittedForm(valid: true),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.']], $controller->flashes);
}
public function testPostValidViaHtmxReturnsHxRedirect(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingService = $this->serviceWithCompleteContactData();
// No remark on the booking and none submitted: the empty string clears rather than keeps.
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, '');
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$bookingService,
$this->submittedForm(valid: true),
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
$request->headers->set('HX-Request', 'true');
$response = $controller->index($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertTrue($response->headers->has('HX-Redirect'));
}
public function testPostInvalidReRendersModalWithoutAccepting(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingService = $this->serviceWithCompleteContactData();
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$bookingService,
$this->submittedForm(valid: false),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertFalse($response->headers->has('HX-Redirect'));
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
}
#[DataProvider('methods')]
public function testSendsToContactPageWhenContactDataIsIncomplete(string $method): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('hasCompleteContactData')->with($booking)->willReturn(false);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$bookingService,
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', $method);
$request->headers->set('HX-Request', 'true');
$response = $controller->index($booking->getUuid(), $request);
self::assertSame('/app_groups_offer_contact?uuid='.$booking->getUuid(), $response->headers->get('HX-Redirect'));
self::assertNull($controller->renderedView);
}
/**
* @return iterable<string, array{string}>
*/
public static function methods(): iterable
{
yield 'opening the modal' => ['GET'];
yield 'submitting it anyway' => ['POST'];
}
public function testRedirectsWhenAlreadyAccepted(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Received);
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testRedirectsWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$linkSigner,
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testRedirectsForUnknownUuid(): void
{
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn(null);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->expects(self::never())->method('isSessionAuthorized');
$controller = new TestableOfferConfirmController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->index('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
private function repositoryReturning(AccommodationBooking $booking): AccommodationBookingRepository
{
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
return $bookingRepository;
}
private function authorizedLinkSigner(): AccommodationBookingLinkSigner
{
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
return $linkSigner;
}
private function serviceWithCompleteContactData(): AccommodationBookingService&MockObject
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('hasCompleteContactData')->willReturn(true);
return $bookingService;
}
private function submittedForm(bool $valid): FormInterface
{
$form = $this->createStub(FormInterface::class);
$form->method('handleRequest')->willReturnSelf();
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn($valid);
return $form;
}
}
final class TestableOfferConfirmController extends ConfirmController
{
public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
public mixed $formData = null;
/** @var array<int, array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
AccommodationBookingRepository $bookingRepository,
AccommodationBookingLinkSigner $linkSigner,
AccommodationBookingService $bookingService,
private readonly ?FormInterface $form = null,
) {
parent::__construct(
$bookingRepository,
$linkSigner,
$bookingService,
new AccommodationTermsUrlProvider(['AT' => '', 'CH' => '', 'IT' => ''], 'https://example.test/agb/'),
);
}
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
$this->formData = $data;
return $this->form ?? throw new \LogicException('No form mock configured for this test.');
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return $response ?? new Response('<html></html>');
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Groups\Offer;
use App\Controller\Groups\Offer\ContactController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ContactControllerTest extends TestCase
{
public function testGetRendersPagePrefilledFromBooking(): void
{
$booking = $this->openBooking();
$dto = new AccommodationBookingDto();
$ctx = new AccommodationBookingContext(accommodation: $booking->getAccommodation(), priceBreakdown: ['total' => 1000]);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('contactDataFromBooking')->with($booking)->willReturn($dto);
$bookingService->method('createOfferContext')->with($booking, $booking->getAccommodation())->willReturn($ctx);
$bookingService->expects(self::never())->method('applyContactData');
$form = $this->createStub(FormInterface::class);
$form->method('handleRequest')->willReturnSelf();
$form->method('isSubmitted')->willReturn(false);
$controller = new TestableOfferContactController(
$this->repositoryReturning($booking),
$this->linkSigner(authorized: true),
$bookingService,
$form,
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/contact.html.twig', $controller->renderedView);
self::assertSame($form, $controller->renderedParameters['form']);
self::assertSame($ctx, $controller->renderedParameters['ctx']);
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
self::assertSame($dto, $controller->formData);
self::assertSame(['email_readonly' => true], $controller->formOptions);
}
public function testPostValidSavesAndReturnsToOffer(): void
{
$booking = $this->openBooking();
$dto = new AccommodationBookingDto();
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('contactDataFromBooking')->willReturn($dto);
$bookingService->expects(self::once())->method('applyContactData')->with($booking, $dto);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferContactController(
$this->repositoryReturning($booking),
$this->linkSigner(authorized: true),
$bookingService,
$this->submittedForm(valid: true),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact', 'POST'));
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
self::assertSame([['type' => 'success', 'message' => 'Deine Kontaktdaten wurden gespeichert. Du kannst die Buchung jetzt abschließen.']], $controller->flashes);
}
public function testPostInvalidReRendersWithoutSaving(): void
{
$booking = $this->openBooking();
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('contactDataFromBooking')->willReturn(new AccommodationBookingDto());
$bookingService->method('createOfferContext')->willReturn(new AccommodationBookingContext(accommodation: $booking->getAccommodation()));
$bookingService->expects(self::never())->method('applyContactData');
$controller = new TestableOfferContactController(
$this->repositoryReturning($booking),
$this->linkSigner(authorized: true),
$bookingService,
$this->submittedForm(valid: false),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/contact.html.twig', $controller->renderedView);
}
#[DataProvider('guards')]
public function testRedirectsToOfferWhenNotEditable(AccommodationBookingStatus $status, bool $authorized): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('applyContactData');
$controller = new TestableOfferContactController(
$this->repositoryReturning($booking),
$this->linkSigner($authorized),
$bookingService,
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact', 'POST'));
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
}
/**
* @return iterable<string, array{AccommodationBookingStatus, bool}>
*/
public static function guards(): iterable
{
yield 'unauthorized session' => [AccommodationBookingStatus::Open, false];
yield 'already accepted' => [AccommodationBookingStatus::Received, true];
yield 'discarded' => [AccommodationBookingStatus::Discarded, true];
}
private function openBooking(): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setAccommodation(new Accommodation());
return $booking;
}
private function repositoryReturning(AccommodationBooking $booking): AccommodationBookingRepository
{
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
return $bookingRepository;
}
private function linkSigner(bool $authorized): AccommodationBookingLinkSigner
{
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn($authorized);
return $linkSigner;
}
private function submittedForm(bool $valid): FormInterface
{
$form = $this->createStub(FormInterface::class);
$form->method('handleRequest')->willReturnSelf();
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn($valid);
return $form;
}
}
final class TestableOfferContactController extends ContactController
{
public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
public mixed $formData = null;
/** @var array<string, mixed> */
public array $formOptions = [];
/** @var array<int, array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
AccommodationBookingRepository $bookingRepository,
AccommodationBookingLinkSigner $linkSigner,
AccommodationBookingService $bookingService,
private readonly ?FormInterface $form = null,
) {
parent::__construct($bookingRepository, $linkSigner, $bookingService);
}
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
$this->formData = $data;
$this->formOptions = $options;
return $this->form ?? throw new \LogicException('No form mock configured for this test.');
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return $response ?? new Response('<html></html>');
}
}
@@ -5,27 +5,19 @@ declare(strict_types=1);
namespace App\Tests\Controller\Groups\Offer; namespace App\Tests\Controller\Groups\Offer;
use App\Controller\Groups\Offer\IndexController; use App\Controller\Groups\Offer\IndexController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\OfferAcceptDto;
use App\Repository\Groups\AccommodationBookingRepository; use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner; use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class IndexControllerTest extends TestCase class IndexControllerTest extends TestCase
{ {
public function testAccessAuthorizesSessionAndRedirectsToView(): void public function testAuthorizesSessionAndRedirectsToView(): void
{ {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open); $booking->setStatus(AccommodationBookingStatus::Open);
@@ -37,20 +29,15 @@ class IndexControllerTest extends TestCase
$linkSigner->method('isValidLinkRequest')->willReturn(true); $linkSigner->method('isValidLinkRequest')->willReturn(true);
$linkSigner->expects(self::once())->method('authorizeSession')->with(self::anything(), $booking); $linkSigner->expects(self::once())->method('authorizeSession')->with(self::anything(), $booking);
$controller = new TestableOfferController( $controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); $response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertInstanceOf(RedirectResponse::class, $response); self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl()); self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
} }
public function testAccessRendersUnavailableForInvalidLink(): void public function testRendersUnavailableForInvalidLink(): void
{ {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
@@ -61,20 +48,15 @@ class IndexControllerTest extends TestCase
$linkSigner->method('isValidLinkRequest')->willReturn(false); $linkSigner->method('isValidLinkRequest')->willReturn(false);
$linkSigner->expects(self::never())->method('authorizeSession'); $linkSigner->expects(self::never())->method('authorizeSession');
$controller = new TestableOfferController( $controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); $response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView); self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
} }
public function testAccessRendersUnavailableForUnknownUuid(): void public function testRendersUnavailableForUnknownUuid(): void
{ {
$bookingRepository = $this->createStub(AccommodationBookingRepository::class); $bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn(null); $bookingRepository->method('findOneBy')->willReturn(null);
@@ -82,21 +64,16 @@ class IndexControllerTest extends TestCase
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->expects(self::never())->method('isValidLinkRequest'); $linkSigner->expects(self::never())->method('isValidLinkRequest');
$controller = new TestableOfferController( $controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->access('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid')); $response = $controller->index('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView); self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
} }
#[DataProvider('statusesTheCustomerMustNotSee')] #[DataProvider('statusesTheCustomerMustNotSee')]
public function testAccessRendersUnavailableForABookingThatIsNotOfferedYet(AccommodationBookingStatus $status): void public function testRendersUnavailableForABookingThatIsNotOfferedYet(AccommodationBookingStatus $status): void
{ {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setStatus($status); $booking->setStatus($status);
@@ -108,14 +85,9 @@ class IndexControllerTest extends TestCase
$linkSigner->method('isValidLinkRequest')->willReturn(true); $linkSigner->method('isValidLinkRequest')->willReturn(true);
$linkSigner->expects(self::never())->method('authorizeSession'); $linkSigner->expects(self::never())->method('authorizeSession');
$controller = new TestableOfferController( $controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); $response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView); self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
@@ -132,353 +104,12 @@ class IndexControllerTest extends TestCase
yield 'requested' => [AccommodationBookingStatus::Requested]; yield 'requested' => [AccommodationBookingStatus::Requested];
yield 'discarded' => [AccommodationBookingStatus::Discarded]; yield 'discarded' => [AccommodationBookingStatus::Discarded];
} }
/**
* The status is re-read on every view, so an authorized session is no free pass: a
* booking pushed back into Entwurf stops showing the offer from that moment on.
*/
#[DataProvider('statusesTheCustomerMustNotSee')]
public function testViewRendersUnavailableForABookingTheCustomerMustNotSee(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->expects(self::never())->method('compute');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$breakdownCalculator,
$this->createStub(AccommodationBookingService::class),
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->view($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
public function testViewRendersOfferWhenSessionAuthorized(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$bookingService->method('loadHotelCmsData')->willReturn(null);
$controller = new TestableOfferController($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->view($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('<html>offer</html>', $response->getContent());
self::assertSame('groups/offer/view.html.twig', $controller->renderedView);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
self::assertSame($booking->getAccommodation(), $controller->renderedParameters['ctx']->accommodation);
}
public function testViewRendersUnavailableWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->view($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
public function testConfirmGetRendersModalWithFreshForm(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setRemarks("Bitte Zimmer im EG\nDanke!");
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
$confirmationForm,
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']);
self::assertInstanceOf(OfferAcceptDto::class, $controller->formData);
self::assertSame("Bitte Zimmer im EG\nDanke!", $controller->formData->remarks, 'the stored remark is prefilled into the modal');
self::assertFalse($controller->formData->termsAccepted);
}
public function testConfirmPostValidAcceptsBookingAndRedirects(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setRemarks('Bitte Zimmer im EG');
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, 'Bitte Zimmer im EG');
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$bookingService,
$confirmationForm,
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
$response = $controller->confirm($booking->getUuid(), $request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.']], $controller->flashes);
}
public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
// No remark on the booking and none submitted: the empty string clears rather than keeps.
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, '');
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$bookingService,
$confirmationForm,
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
$request->headers->set('HX-Request', 'true');
$response = $controller->confirm($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertTrue($response->headers->has('HX-Redirect'));
}
public function testConfirmPostInvalidReRendersModalWithoutAccepting(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$bookingService,
$confirmationForm,
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertFalse($response->headers->has('HX-Redirect'));
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
}
public function testConfirmRedirectsWhenAlreadyAccepted(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Received);
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testConfirmRedirectsWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testConfirmRedirectsForUnknownUuid(): void
{
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn(null);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->expects(self::never())->method('isSessionAuthorized');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->confirm('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
} }
final class TestableOfferController extends IndexController final class TestableOfferIndexController extends IndexController
{ {
public ?string $renderedView = null; public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
public mixed $formData = null;
public function __construct(
AccommodationBookingRepository $bookingRepository,
AccommodationBookingLinkSigner $linkSigner,
AccommodationBookingBreakdownCalculator $breakdownCalculator,
AccommodationBookingService $bookingService,
private readonly ?FormInterface $confirmationForm = null,
) {
parent::__construct(
$bookingRepository,
$linkSigner,
$breakdownCalculator,
$bookingService,
new AccommodationTermsUrlProvider(['AT' => '', 'CH' => '', 'IT' => ''], 'https://example.test/agb/'),
);
}
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
$this->formData = $data;
return $this->confirmationForm ?? throw new \LogicException('No confirmation form mock configured for this test.');
}
protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null
{
return 'https://example.test/agb/';
}
/**
* @var array<int, array{type: string, message: mixed}>
*/
public array $flashes = [];
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/** /**
* @param array<string, mixed> $parameters * @param array<string, mixed> $parameters
*/ */
@@ -490,17 +121,7 @@ final class TestableOfferController extends IndexController
protected function render(string $view, array $parameters = [], ?Response $response = null): Response protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{ {
$this->renderedView = $view; $this->renderedView = $view;
$this->renderedParameters = $parameters;
if ('groups/offer/view.html.twig' === $view) { return $response ?? new Response('<html>unavailable</html>');
$content = null !== $parameters['booking']->getAcceptedAt() ? '<html>confirmed</html>' : '<html>offer</html>';
} else {
$content = '<html>unavailable</html>';
}
$response ??= new Response();
$response->setContent($content);
return $response;
} }
} }
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Groups\Offer;
use App\Controller\Groups\Offer\ViewController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class ViewControllerTest extends TestCase
{
/**
* The status is re-read on every view, so an authorized session is no free pass: a
* booking pushed back into Entwurf stops showing the offer from that moment on.
*/
#[DataProvider('statusesTheCustomerMustNotSee')]
public function testRendersUnavailableForABookingTheCustomerMustNotSee(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('createOfferContext');
$controller = new TestableOfferViewController($bookingRepository, $linkSigner, $bookingService);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->index($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesTheCustomerMustNotSee(): iterable
{
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'requested' => [AccommodationBookingStatus::Requested];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
public function testRendersOfferWhenSessionAuthorized(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$ctx = new AccommodationBookingContext(accommodation: $booking->getAccommodation(), priceBreakdown: ['total' => 1000]);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('createOfferContext')->with($booking, $booking->getAccommodation())->willReturn($ctx);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferViewController($bookingRepository, $linkSigner, $bookingService);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->index($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/view.html.twig', $controller->renderedView);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
self::assertSame($ctx, $controller->renderedParameters['ctx']);
}
public function testRendersUnavailableWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferViewController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
}
final class TestableOfferViewController extends ViewController
{
public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return $response ?? new Response('<html></html>');
}
}
@@ -19,7 +19,7 @@ class PendingRoleApprovalsWidgetProviderTest extends TestCase
$user = (new User('[email protected]')) $user = (new User('[email protected]'))
->setFirstName('Rita') ->setFirstName('Rita')
->setLastName('Vorschlag') ->setLastName('Vorschlag')
->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)]) ->setRoles([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)])
; ;
$widget = $this->provider([$user])->build(); $widget = $this->provider([$user])->build();
@@ -27,7 +27,7 @@ class PendingRoleApprovalsWidgetProviderTest extends TestCase
$this->assertNotNull($widget); $this->assertNotNull($widget);
$this->assertCount(1, $widget->entries); $this->assertCount(1, $widget->entries);
$this->assertStringContainsString('Rita Vorschlag', $widget->entries[0]->label); $this->assertStringContainsString('Rita Vorschlag', $widget->entries[0]->label);
$this->assertStringContainsString(Role::labels()[Role::GROUPS_ADMIN], $widget->entries[0]->label); $this->assertStringContainsString(Role::labels()[Role::HOUSE_MANAGER], $widget->entries[0]->label);
} }
/** /**
@@ -46,7 +46,7 @@ class RecentLogEntriesWidgetProviderTest extends TestCase
$this->assertStringEndsWith(str_repeat('a', 60).'…', $widget->entries[0]->label); $this->assertStringEndsWith(str_repeat('a', 60).'…', $widget->entries[0]->label);
} }
public function testAsksForFiveEntriesAndRequiresAdmin(): void public function testAsksForFiveEntriesAndRequiresCustomerExpert(): void
{ {
$repository = $this->createMock(LogEntryRepository::class); $repository = $this->createMock(LogEntryRepository::class);
$repository->expects($this->once()) $repository->expects($this->once())
@@ -57,7 +57,7 @@ class RecentLogEntriesWidgetProviderTest extends TestCase
$provider = new RecentLogEntriesWidgetProvider($repository, $this->urlGenerator()); $provider = new RecentLogEntriesWidgetProvider($repository, $this->urlGenerator());
$this->assertSame(Role::ADMIN, $provider->getRequiredRole()); $this->assertSame(Role::CUSTOMER_EXPERT, $provider->getRequiredRole());
$this->assertNotNull($provider->build()); $this->assertNotNull($provider->build());
} }
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Tests\Dashboard;
use App\Dashboard\Widget\StuckBookingDraftsWidgetProvider;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Repository\BookingEditDraftRepository;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class StuckBookingDraftsWidgetProviderTest extends TestCase
{
public function testAsksForDraftsOlderThanAWeekAndRequiresCustomerExpert(): void
{
$repository = $this->createMock(BookingEditDraftRepository::class);
$repository->expects($this->once())
->method('findStuck')
->with(7, 10)
->willReturn([])
;
$provider = new StuckBookingDraftsWidgetProvider($repository, $this->urlGenerator());
$this->assertSame(Role::CUSTOMER_EXPERT, $provider->getRequiredRole());
$this->assertNotNull($provider->build());
}
public function testSaysSoWhenNothingIsStuck(): void
{
$widget = $this->provider([])->build();
$this->assertNotNull($widget);
$this->assertSame([], $widget->entries);
$this->assertSame('Keine festhängenden Buchungsänderungen.', $widget->emptyText);
}
public function testLabelNamesTheBookingTheCustomerAndHowLongItHasBeenStuck(): void
{
$widget = $this->provider([$this->draft(111564, '[email protected]', 61)])->build();
$this->assertNotNull($widget);
$this->assertStringContainsString('Vorgang 111564', $widget->entries[0]->label);
$this->assertStringContainsString('[email protected]', $widget->entries[0]->label);
$this->assertStringContainsString('seit 61 Tagen', $widget->entries[0]->label);
}
public function testEntriesLinkToTheDraft(): void
{
$widget = $this->provider([$this->draft(111564, '[email protected]', 8)])->build();
$this->assertNotNull($widget);
$this->assertNotNull($widget->entries[0]->url);
$this->assertSame('Alle Buchungsentwürfe', $widget->actionLabel);
}
/**
* @param BookingEditDraft[] $drafts
*/
private function provider(array $drafts): StuckBookingDraftsWidgetProvider
{
$repository = $this->createStub(BookingEditDraftRepository::class);
$repository->method('findStuck')->willReturn($drafts);
return new StuckBookingDraftsWidgetProvider($repository, $this->urlGenerator());
}
private function urlGenerator(): UrlGeneratorInterface
{
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
$urlGenerator->method('generate')->willReturnCallback(
static fn (string $route): string => '/'.str_replace('_', '/', substr($route, \strlen('app_'))),
);
return $urlGenerator;
}
private function draft(int $bookingNumber, string $email, int $ageDays): BookingEditDraft
{
$draft = new BookingEditDraft(new User($email), 98787, new \DateTimeImmutable('+60 days'), []);
$draft->setBookingNumber($bookingNumber);
// createdAt is stamped by the constructor and has no setter - the age is the whole point of
// this widget, so it is set directly rather than asserted away.
$createdAt = new \ReflectionProperty(BookingEditDraft::class, 'createdAt');
$createdAt->setValue($draft, new \DateTimeImmutable(sprintf('-%d days', $ageDays)));
return $draft;
}
}
@@ -18,7 +18,7 @@ class UserFilterDtoTest extends TestCase
$chip = $filter->activeFilters()[0]; $chip = $filter->activeFilters()[0];
self::assertSame('Rolle', $chip->label); self::assertSame('Rolle', $chip->label);
self::assertSame('Preisrechner Admin', $chip->value); self::assertSame('GRO Admin', $chip->value);
self::assertSame(['role'], $chip->removeKeys); self::assertSame(['role'], $chip->removeKeys);
} }
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantFieldOptionsProvider;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
use App\Service\ServiceAvailabilityCalculator;
use App\Service\ServiceLabelFormatter;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Edit mode applies the availability rules instead of bypassing them.
*
* Edit used to skip availability entirely for every category except ski passes, so a course
* that had sold out or moved to 'Anfrage' was still offered. Selecting one made BusPro refuse
* the whole update ("Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers
* (F)"), because the participants of an existing booking are fixed at status 'F'.
*
* Courses stand in for the categories that render read-only rather than dropping the option.
*/
class ParticipantFieldOptionsProviderEditAvailabilityTest extends TestCase
{
private ParticipantFieldOptionsProvider $provider;
protected function setUp(): void
{
$translator = $this->createStub(TranslatorInterface::class);
$translator->method('trans')->willReturnArgument(0);
$this->provider = new ParticipantFieldOptionsProvider(
new ServiceAvailabilityCalculator(),
$this->createStub(InsuranceManager::class),
$this->createStub(BookingPriceCalculator::class),
new ServiceLabelFormatter(),
$translator
);
}
public function testOnRequestCourseIsReadonlyInEditMode(): void
{
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createEditBookingDto([$course]);
$attributes = $this->getChoiceAttributes($bookingDto, $course);
$this->assertArrayHasKey('readonly', $attributes);
$this->assertSame(
'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.',
$attributes['data-tooltip']
);
}
public function testSoldOutCourseIsReadonlyInEditMode(): void
{
$course = $this->createCourse(id: 1, available: 0);
$bookingDto = $this->createEditBookingDto([$course]);
$attributes = $this->getChoiceAttributes($bookingDto, $course);
$this->assertArrayHasKey('readonly', $attributes);
$this->assertSame('ausgebucht', $attributes['data-tooltip']);
}
public function testOnRequestCourseStaysSelectableWhenAlreadyBooked(): void
{
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createEditBookingDto([$course], heldServiceIds: [1]);
$attributes = $this->getChoiceAttributes($bookingDto, $course);
$this->assertArrayNotHasKey('readonly', $attributes);
$this->assertArrayNotHasKey('data-tooltip', $attributes);
}
public function testUnlimitedCourseIsNotReadonlyInEditMode(): void
{
// A null contingent means unlimited. The previous edit branch inverted this and made
// such a service read-only for anyone who did not already hold it.
$course = $this->createCourse(id: 1, available: null);
$bookingDto = $this->createEditBookingDto([$course]);
$attributes = $this->getChoiceAttributes($bookingDto, $course);
$this->assertArrayNotHasKey('readonly', $attributes);
}
public function testOnRequestCourseStaysSelectableInCreateMode(): void
{
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createBookingDto([$course]);
$this->assertSame(BookingDto::MODE_CREATE, $bookingDto->getMode());
$attributes = $this->getChoiceAttributes($bookingDto, $course);
$this->assertArrayNotHasKey('readonly', $attributes);
}
/** @return array<string, mixed> */
private function getChoiceAttributes(BookingDto $bookingDto, Service $service): array
{
$options = $this->provider->getFieldOptions('courses', $bookingDto, 0);
return $options['choice_attr']($service);
}
/** @param Service[] $courses */
private function createBookingDto(array $courses): BookingDto
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2027-01-09');
$travel->dateTo = new \DateTimeImmutable('2027-01-16');
foreach ($courses as $course) {
$travel->additionalServices[$course->id] = $course;
}
$bookingDto = new BookingDto($travel, 1);
$participant = new ParticipantDto();
$participant->index = 0;
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01');
$bookingDto->participants[0] = $participant;
return $bookingDto;
}
/**
* @param Service[] $courses
* @param list<int> $heldServiceIds IDs participant 0 already holds in the booking
*/
private function createEditBookingDto(array $courses, array $heldServiceIds = []): BookingDto
{
$bookingDto = $this->createBookingDto($courses);
$heldServices = [];
foreach ($heldServiceIds as $serviceId) {
$held = clone $bookingDto->travel->additionalServices[$serviceId];
$held->mapping = [0];
$heldServices[$serviceId] = $held;
}
// A non-null booking is what puts the DTO into edit mode
$booking = new Booking();
$booking->additionalServices = $heldServices;
$bookingDto->booking = $booking;
return $bookingDto;
}
private function createCourse(int $id, ?int $available = 10, string $status = Constants::STATUS_AVAILABLE): Service
{
$service = new Service();
$service->id = $id;
$service->label = sprintf('Kurs %d', $id);
$service->subType = Constants::TOKEN_COURSES;
$service->category = Constants::CATEGORY_ADDITIONAL;
$service->price = (float) $id;
$service->available = $available;
$service->status = $status;
return $service;
}
}
@@ -16,8 +16,8 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/** /**
* The notification goes to the people who can act on it, and to nobody else: an account merely * The notification goes to the addresses configured in config/services.yaml, and to nobody else:
* nominated for ROLE_ADMIN must not be told about other people's nominations. * the audience does not follow whoever currently holds ROLE_ADMIN.
*/ */
class RoleNominationHandlerTest extends TestCase class RoleNominationHandlerTest extends TestCase
{ {
@@ -31,14 +31,11 @@ class RoleNominationHandlerTest extends TestCase
private ?int $generatedReferenceType = null; private ?int $generatedReferenceType = null;
public function testMailsEveryAdministrator(): void public function testMailsTheConfiguredRecipients(): void
{ {
$handler = $this->handler( $handler = $this->handler(
$this->user(7, '[email protected]'), $this->user(7, '[email protected]'),
[ ['[email protected]', '[email protected]'],
$this->user(1, '[email protected]', [Role::ADMIN]),
$this->user(2, '[email protected]', [Role::ADMIN]),
],
); );
$handler(new RoleNominationMessage(7, [Role::ADMIN])); $handler(new RoleNominationMessage(7, [Role::ADMIN]));
@@ -57,14 +54,14 @@ class RoleNominationHandlerTest extends TestCase
public function testAMissingAccountIsANoOp(): void public function testAMissingAccountIsANoOp(): void
{ {
$handler = $this->handler(null, [$this->user(1, '[email protected]', [Role::ADMIN])]); $handler = $this->handler(null, ['[email protected]']);
$handler(new RoleNominationMessage(7, [Role::ADMIN])); $handler(new RoleNominationMessage(7, [Role::ADMIN]));
self::assertNull($this->sentOptions); self::assertNull($this->sentOptions);
} }
public function testWithoutAnAdministratorNothingIsSent(): void public function testWithoutAConfiguredRecipientNothingIsSent(): void
{ {
$handler = $this->handler($this->user(7, '[email protected]'), []); $handler = $this->handler($this->user(7, '[email protected]'), []);
@@ -88,13 +85,12 @@ class RoleNominationHandlerTest extends TestCase
} }
/** /**
* @param User[] $administrators * @param string[] $recipients
*/ */
private function handler(?User $nominee, array $administrators): RoleNominationHandler private function handler(?User $nominee, array $recipients): RoleNominationHandler
{ {
$userRepository = $this->createStub(UserRepository::class); $userRepository = $this->createStub(UserRepository::class);
$userRepository->method('find')->willReturn($nominee); $userRepository->method('find')->willReturn($nominee);
$userRepository->method('findAdministrators')->willReturn($administrators);
$mailer = $this->createStub(Mailer::class); $mailer = $this->createStub(Mailer::class);
$mailer $mailer
@@ -123,6 +119,7 @@ class RoleNominationHandlerTest extends TestCase
$mailer, $mailer,
new RoleApprovalUrlGenerator($urlGenerator), new RoleApprovalUrlGenerator($urlGenerator),
$this->createStub(LoggerInterface::class), $this->createStub(LoggerInterface::class),
$recipients,
); );
} }
} }
+105 -16
View File
@@ -43,7 +43,7 @@ class BpnAuthenticatorTest extends TestCase
{ {
$persisted = null; $persisted = null;
$authenticator = $this->authenticator( $authenticator = $this->authenticator(
$this->crmAttributes([Role::ADMIN, Role::TEAMER], ['SSL', 'SSL']), $this->crmAttributes([Role::HOUSE_MANAGER, Role::TEAMER], ['SSL', 'SSL']),
null, null,
$persisted, $persisted,
); );
@@ -51,7 +51,7 @@ class BpnAuthenticatorTest extends TestCase
$user = $this->loadUser($authenticator); $user = $this->loadUser($authenticator);
self::assertSame($persisted, $user); self::assertSame($persisted, $user);
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::ADMIN)], $user->getRoles()); self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)], $user->getRoles());
self::assertSame(['SSL'], $user->getHotelCodes()); self::assertSame(['SSL'], $user->getHotelCodes());
} }
@@ -61,7 +61,7 @@ class BpnAuthenticatorTest extends TestCase
$persisted = null; $persisted = null;
$authenticator = $this->authenticator( $authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::GROUPS_ADMIN], []), $this->crmAttributes([Role::TEAMER, Role::HOUSE_MANAGER], []),
$existing, $existing,
$persisted, $persisted,
); );
@@ -70,32 +70,61 @@ class BpnAuthenticatorTest extends TestCase
self::assertSame($existing, $persisted, 'a login must not create a second account'); self::assertSame($existing, $persisted, 'a login must not create a second account');
self::assertSame( self::assertSame(
['ROLE_USER', Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)], ['ROLE_USER', Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)],
$user->getRoles(), $user->getRoles(),
); );
self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced'); self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced');
} }
public function testTheLoginAddressIsNormalizedAndRewritesAStoredCasing(): void
{
// An account from before the address was normalized: it carries the casing of whatever
// its very first login happened to type.
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER]);
$persisted = null;
$persistedPassword = null;
$lookupCriteria = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER], []),
$existing,
$persisted,
$persistedPassword,
null,
$lookupCriteria,
);
$user = $this->loadUser($authenticator, ' [email protected] ');
self::assertSame($existing, $persisted, 'a differently cased address is the same account');
self::assertSame(['email' => '[email protected]'], $lookupCriteria);
self::assertSame(
'[email protected]',
$user->getEmail(),
'the stored casing follows the login rather than staying frozen',
);
}
public function testApprovedRoleSurvivesTheNextLogin(): void public function testApprovedRoleSurvivesTheNextLogin(): void
{ {
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]); $existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]);
$persisted = null; $persisted = null;
$authenticator = $this->authenticator( $authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::GROUPS_MANAGER], []), $this->crmAttributes([Role::TEAMER, Role::HOUSE_MANAGER], []),
$existing, $existing,
$persisted, $persisted,
); );
self::assertSame( self::assertSame(
['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], ['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER],
$this->loadUser($authenticator)->getRoles(), $this->loadUser($authenticator)->getRoles(),
); );
} }
public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void
{ {
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]); $existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]);
$persisted = null; $persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted); $authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
@@ -140,7 +169,7 @@ class BpnAuthenticatorTest extends TestCase
public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void
{ {
$existing = (new User('[email protected]')) $existing = (new User('[email protected]'))
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]) ->setRoles([Role::TEAMER, Role::HOUSE_MANAGER])
->setHotelCodes(['DKS']) ->setHotelCodes(['DKS'])
; ;
@@ -155,7 +184,7 @@ class BpnAuthenticatorTest extends TestCase
$user = $this->loadUser($authenticator); $user = $this->loadUser($authenticator);
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles()); self::assertSame(['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER], $user->getRoles());
self::assertSame(['DKS'], $user->getHotelCodes()); self::assertSame(['DKS'], $user->getHotelCodes());
} }
@@ -240,12 +269,62 @@ class BpnAuthenticatorTest extends TestCase
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles()); self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
} }
public function testAStaffContactAddressOnTheBusProRecordDoesNotMakeTheLoginStaff(): void
{
$persisted = null;
$persistedPassword = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::ADMIN, Role::TEAMER], []),
null,
$persisted,
$persistedPassword,
'[email protected]',
);
$user = $this->loadUser($authenticator, '[email protected]');
// BusPro accepts any address on the record as a login and answers with the first contact
// address regardless of which one was used, so only the typed address may decide. Reading
// the response instead would hand ROLE_EMPLOYEE — and every EMPLOYEE_ONLY role with it —
// to anyone who can add a staff address to their own BusPro record.
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
self::assertSame([], $this->dispatched);
}
public function testEmployeeOnlyClaimFromAnotherDomainIsIgnored(): void
{
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN, Role::TEAMER], []), null, $persisted);
$user = $this->loadUser($authenticator, '[email protected]');
// Not even a nomination, so there is nothing for an administrator to be told about.
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
self::assertSame([], $this->dispatched);
}
public function testEmployeeOnlyRoleIsRevokedWhenTheAddressIsNotStaff(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::ADMIN, Role::HOUSE_MANAGER]);
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::ADMIN, Role::HOUSE_MANAGER], []),
$existing,
$persisted,
);
$user = $this->loadUser($authenticator, '[email protected]');
// Only the EMPLOYEE_ONLY role goes; a Hausleitung does not need a staff address.
self::assertSame(['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER], $user->getRoles());
}
public function testANewNominationIsAnnouncedOnce(): void public function testANewNominationIsAnnouncedOnce(): void
{ {
$persisted = null; $persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted); $authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted);
$user = $this->loadUser($authenticator); $user = $this->loadUser($authenticator, '[email protected]');
self::assertCount(1, $this->dispatched); self::assertCount(1, $this->dispatched);
$message = $this->dispatched[0]; $message = $this->dispatched[0];
@@ -257,11 +336,11 @@ class BpnAuthenticatorTest extends TestCase
public function testAStandingNominationIsNotAnnouncedAgain(): void public function testAStandingNominationIsNotAnnouncedAgain(): void
{ {
$existing = (new User('[email protected]'))->setRoles([Role::pending(Role::ADMIN)]); $existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE, Role::pending(Role::ADMIN)]);
$persisted = null; $persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted); $authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
$this->loadUser($authenticator); $this->loadUser($authenticator, '[email protected]');
// The nomination has not changed, so there is nothing new to tell an administrator about. // The nomination has not changed, so there is nothing new to tell an administrator about.
self::assertSame([], $this->dispatched); self::assertSame([], $this->dispatched);
@@ -269,11 +348,11 @@ class BpnAuthenticatorTest extends TestCase
public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void
{ {
$existing = (new User('[email protected]'))->setRoles([Role::ADMIN]); $existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE, Role::ADMIN]);
$persisted = null; $persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted); $authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
$this->loadUser($authenticator); $this->loadUser($authenticator, '[email protected]');
self::assertSame([], $this->dispatched); self::assertSame([], $this->dispatched);
} }
@@ -293,17 +372,27 @@ class BpnAuthenticatorTest extends TestCase
?User $existing, ?User $existing,
?User &$persisted, ?User &$persisted,
?string &$persistedPassword = null, ?string &$persistedPassword = null,
?string $contactEmail = null,
?array &$lookupCriteria = null,
): BpnAuthenticator { ): BpnAuthenticator {
$personalData = new PersonalData(); $personalData = new PersonalData();
$personalData->personId = 42; $personalData->personId = 42;
$personalData->addressId = 4711; $personalData->addressId = 4711;
$personalData->communication->email = $contactEmail;
$apiClient = $this->createStub(ApiClient::class); $apiClient = $this->createStub(ApiClient::class);
$apiClient->method('getPersonalData')->willReturn($personalData); $apiClient->method('getPersonalData')->willReturn($personalData);
$apiClient->method('getCrmAttributes')->willReturn($crmAttributes); $apiClient->method('getCrmAttributes')->willReturn($crmAttributes);
$repository = $this->createStub(EntityRepository::class); $repository = $this->createStub(EntityRepository::class);
$repository->method('findOneBy')->willReturn($existing); $repository
->method('findOneBy')
->willReturnCallback(static function (array $criteria) use ($existing, &$lookupCriteria): ?User {
$lookupCriteria = $criteria;
return $existing;
})
;
$entityManager = $this->createStub(EntityManagerInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class);
$entityManager->method('getRepository')->willReturn($repository); $entityManager->method('getRepository')->willReturn($repository);
+88 -13
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Security; namespace App\Tests\Security;
use App\Security\Role; use App\Security\Role;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -15,27 +16,46 @@ class RoleTest extends TestCase
{ {
public function testAdministrativeClaimOnlyProducesANomination(): void public function testAdministrativeClaimOnlyProducesANomination(): void
{ {
$roles = Role::sync([], [Role::ADMIN, Role::GROUPS_ADMIN, Role::TEAMER]); $roles = Role::sync([], [Role::HOUSE_MANAGER, Role::GROUPS_ADMIN, Role::TEAMER, Role::EMPLOYEE]);
self::assertSame( self::assertSame(
[Role::TEAMER, Role::pending(Role::ADMIN), Role::pending(Role::GROUPS_ADMIN)], [Role::TEAMER, Role::EMPLOYEE, Role::pending(Role::HOUSE_MANAGER), Role::pending(Role::GROUPS_ADMIN)],
$roles, $roles,
); );
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles)); self::assertSame([Role::TEAMER, Role::EMPLOYEE], Role::effectiveOnly($roles));
}
public function testCustomerExpertClaimOnlyProducesANomination(): void
{
$roles = Role::sync([], [Role::EMPLOYEE, Role::CUSTOMER_EXPERT]);
self::assertSame([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], $roles);
self::assertSame([Role::EMPLOYEE], Role::effectiveOnly($roles));
self::assertSame(
[Role::CUSTOMER_EXPERT => 'KO-Expert:in'],
Role::nominatedFrom($roles),
);
}
public function testCustomerExpertCanBeApprovedForStaff(): void
{
$roles = Role::approve([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], Role::CUSTOMER_EXPERT);
self::assertSame([Role::EMPLOYEE, Role::CUSTOMER_EXPERT], $roles);
} }
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
{ {
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]); $roles = Role::sync([Role::TEAMER, Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER, Role::TEAMER]);
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles); self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], $roles);
} }
public function testRoleTheCrmNoLongerClaimsIsRevoked(): void public function testRoleTheCrmNoLongerClaimsIsRevoked(): void
{ {
// Both halves go: BusPro is the source of truth for the granted role as much as for // Both halves go: BusPro is the source of truth for the granted role as much as for
// the nomination. // the nomination.
$roles = Role::sync([Role::TEAMER, Role::ADMIN, Role::pending(Role::MANAGER)], [Role::TEAMER]); $roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN, Role::pending(Role::HOUSE_MANAGER)], [Role::TEAMER]);
self::assertSame([Role::TEAMER], $roles); self::assertSame([Role::TEAMER], $roles);
} }
@@ -50,8 +70,8 @@ class RoleTest extends TestCase
// The nomination stays visible — it is what an approver acts on — but grants nothing, // The nomination stays visible — it is what an approver acts on — but grants nothing,
// so the account is a customer in the meantime. // so the account is a customer in the meantime.
self::assertSame( self::assertSame(
[Role::pending(Role::ADMIN), Role::CUSTOMER], [Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER],
Role::sync([], [Role::ADMIN]), Role::sync([], [Role::HOUSE_MANAGER]),
); );
self::assertSame([Role::CUSTOMER], Role::sync([], [])); self::assertSame([Role::CUSTOMER], Role::sync([], []));
} }
@@ -71,10 +91,10 @@ class RoleTest extends TestCase
public function testApprovalTurnsTheNominationIntoTheRole(): void public function testApprovalTurnsTheNominationIntoTheRole(): void
{ {
$roles = Role::approve([Role::pending(Role::ADMIN), Role::CUSTOMER], Role::ADMIN); $roles = Role::approve([Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER], Role::HOUSE_MANAGER);
// The customer fallback goes with it: the account now holds an effective role. // The customer fallback goes with it: the account now holds an effective role.
self::assertSame([Role::ADMIN], $roles); self::assertSame([Role::HOUSE_MANAGER], $roles);
} }
public function testApprovingARoleWithoutANominationIsRefused(): void public function testApprovingARoleWithoutANominationIsRefused(): void
@@ -86,11 +106,11 @@ class RoleTest extends TestCase
public function testEffectiveRolesExcludeNominationsAndTheImplicitRoleUser(): void public function testEffectiveRolesExcludeNominationsAndTheImplicitRoleUser(): void
{ {
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::ADMIN)]; $roles = [Role::USER, Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)];
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles)); self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
self::assertSame([Role::pending(Role::ADMIN)], Role::pendingOnly($roles)); self::assertSame([Role::pending(Role::HOUSE_MANAGER)], Role::pendingOnly($roles));
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles)); self::assertSame([Role::HOUSE_MANAGER => 'Hausleitung'], Role::nominatedFrom($roles));
} }
public function testEmployeeIsGrantedOutrightAndDisplacesTheCustomerFallback(): void public function testEmployeeIsGrantedOutrightAndDisplacesTheCustomerFallback(): void
@@ -114,6 +134,61 @@ class RoleTest extends TestCase
); );
} }
/**
* @return iterable<string, array{string}>
*/
public static function employeeOnlyRoles(): iterable
{
foreach (Role::EMPLOYEE_ONLY as $role) {
yield $role => [$role];
}
}
#[DataProvider('employeeOnlyRoles')]
public function testEmployeeOnlyClaimWithoutEmployeeIsNotNominated(string $role): void
{
self::assertSame([Role::TEAMER], Role::sync([], [Role::TEAMER, $role]));
}
#[DataProvider('employeeOnlyRoles')]
public function testEmployeeOnlyRoleIsRevokedWithoutEmployee(string $role): void
{
// The CRM still claims it, but the account is no longer staff: the claim counts as not
// made, and a role approved back when it was staff goes with it.
self::assertSame(
[Role::TEAMER],
Role::sync([Role::TEAMER, Role::EMPLOYEE, $role], [Role::TEAMER, $role]),
);
}
#[DataProvider('employeeOnlyRoles')]
public function testEmployeeOnlyRoleSurvivesTheNextSyncForStaff(string $role): void
{
self::assertSame(
[Role::EMPLOYEE, $role],
Role::sync([Role::EMPLOYEE, $role], [Role::EMPLOYEE, $role]),
);
}
public function testHouseManagerDoesNotNeedEmployee(): void
{
// A Hausleitung signs in with the hotel's own address.
self::assertSame([Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER], Role::sync([], [Role::HOUSE_MANAGER]));
self::assertSame([Role::HOUSE_MANAGER], Role::sync([Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER]));
}
public function testStaleEmployeeOnlyNominationWithoutEmployeeCannotBeApproved(): void
{
// A marker from before the rule, still stored until the account's next login.
$roles = [Role::TEAMER, Role::pending(Role::ADMIN)];
self::assertSame([], Role::nominatedFrom($roles));
$this->expectException(\InvalidArgumentException::class);
Role::approve($roles, Role::ADMIN);
}
public function testEmployeeAndTeamerCoexist(): void public function testEmployeeAndTeamerCoexist(): void
{ {
self::assertSame( self::assertSame(
@@ -28,6 +28,7 @@ use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Validation;
class AccommodationBookingServiceTest extends TestCase class AccommodationBookingServiceTest extends TestCase
{ {
@@ -1010,6 +1011,90 @@ class AccommodationBookingServiceTest extends TestCase
return $booking; return $booking;
} }
public function testHasCompleteContactDataAcceptsAFullyFilledBooking(): void
{
$service = $this->createServiceWithAccommodation();
self::assertTrue($service->hasCompleteContactData($this->createBookingWithContactData()));
}
/**
* An offer the office saved with only the fields its own form requires.
*/
public function testHasCompleteContactDataRejectsWhatTheOfficeMayLeaveOut(): void
{
$service = $this->createServiceWithAccommodation();
$booking = $this->createBookingWithContactData();
$booking->setPhone(null);
self::assertFalse($service->hasCompleteContactData($booking));
}
public function testHasCompleteContactDataAppliesTheStep3Constraints(): void
{
$service = $this->createServiceWithAccommodation();
$booking = $this->createBookingWithContactData();
$booking->setSalutation('Dr.');
self::assertFalse($service->hasCompleteContactData($booking));
}
public function testApplyContactDataRoundTripsAndFlushes(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$dto = $service->contactDataFromBooking($this->createBookingWithContactData());
$dto->phone = '0171 999999';
$booking = new AccommodationBooking();
$booking->setRemarks('bleibt');
$service->applyContactData($booking, $dto);
self::assertSame('Skiclub Nord', $booking->getGroupName());
self::assertSame('Frau', $booking->getSalutation());
self::assertSame('Erika', $booking->getFirstName());
self::assertSame('Mustermann', $booking->getLastName());
self::assertSame('[email protected]', $booking->getEmail());
self::assertSame('0171 999999', $booking->getPhone());
self::assertSame('Hauptstraße 1', $booking->getStreet());
self::assertSame('12345', $booking->getZip());
self::assertSame('Musterstadt', $booking->getCity());
self::assertSame('bleibt', $booking->getRemarks(), 'remarks are not contact data');
}
public function testCreateOfferContextCombinesCmsDataAndStoredBreakdown(): void
{
$booking = new AccommodationBooking();
$accommodation = (new Accommodation())->setCalendarCode('HOTEL');
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]);
$service = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator);
$ctx = $service->createOfferContext($booking, $accommodation);
self::assertSame($accommodation, $ctx->accommodation);
self::assertSame(['total' => 1000], $ctx->priceBreakdown);
}
private function createBookingWithContactData(): AccommodationBooking
{
return (new AccommodationBooking())
->setGroupName('Skiclub Nord')
->setSalutation('Frau')
->setFirstName('Erika')
->setLastName('Mustermann')
->setEmail('[email protected]')
->setPhone('0171 123456')
->setStreet('Hauptstraße 1')
->setZip('12345')
->setCity('Musterstadt');
}
/** /**
* @param AccommodationPrice[] $prices * @param AccommodationPrice[] $prices
*/ */
@@ -1051,6 +1136,7 @@ class AccommodationBookingServiceTest extends TestCase
$linkSigner ?? $this->createStub(AccommodationBookingLinkSigner::class), $linkSigner ?? $this->createStub(AccommodationBookingLinkSigner::class),
$breakdownCalculator ?? $this->createStub(AccommodationBookingBreakdownCalculator::class), $breakdownCalculator ?? $this->createStub(AccommodationBookingBreakdownCalculator::class),
$pdfGenerator ?? $this->createStub(AccommodationBookingPdfGenerator::class), $pdfGenerator ?? $this->createStub(AccommodationBookingPdfGenerator::class),
Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(),
'[email protected]', '[email protected]',
); );
} }
@@ -16,6 +16,7 @@ use App\Repository\BookingEditDraftRepository;
use App\Service\BookingChangeTracker; use App\Service\BookingChangeTracker;
use App\Service\BookingEditDraftManager; use App\Service\BookingEditDraftManager;
use App\Service\BookingEditDraftMerger; use App\Service\BookingEditDraftMerger;
use App\Service\ServiceAvailabilityCalculator;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
@@ -37,7 +38,7 @@ class BookingEditDraftManagerMutabilityTest extends TestCase
$this->createStub(BookingEditDraftRepository::class), $this->createStub(BookingEditDraftRepository::class),
$this->createStub(EntityManagerInterface::class), $this->createStub(EntityManagerInterface::class),
$this->createStub(BookingChangeTracker::class), $this->createStub(BookingChangeTracker::class),
new BookingEditDraftMerger(), new BookingEditDraftMerger(new ServiceAvailabilityCalculator()),
new NullLogger(), new NullLogger(),
); );
} }
@@ -9,6 +9,7 @@ use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Service\BookingEditDraftMerger; use App\Service\BookingEditDraftMerger;
use App\Service\ServiceAvailabilityCalculator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -26,7 +27,7 @@ class BookingEditDraftMergerRoomAssignmentTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->merger = new BookingEditDraftMerger(); $this->merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator());
} }
public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\BookingEditDraftMerger;
use App\Service\ServiceAvailabilityCalculator;
use PHPUnit\Framework\TestCase;
/**
* Tests that draft restoration does not re-arm a selection BusPro would reject.
*
* A draft can be weeks old. Restoring a selection for a service that has since sold out or
* moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice comes back on every
* re-entry and BusPro refuses the whole update again. Services the participant already holds
* still restore, because withdrawing one breaks the Leistung/Teilnehmer counts.
*/
class BookingEditDraftMergerServiceAvailabilityTest extends TestCase
{
private BookingEditDraftMerger $merger;
protected function setUp(): void
{
$this->merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator());
}
public function testDraftDropsSkiPassThatIsNowOnRequest(): void
{
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST);
$travel = $this->createTravel([60 => $skiPass]);
$participant = new ParticipantDto();
$participant->mutable = true;
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking());
$this->assertNull($participant->skiPass);
$this->assertSame(['Skipass 6 Tage'], $dropped);
}
public function testDraftRestoresSkiPassThatIsStillFree(): void
{
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage');
$travel = $this->createTravel([60 => $skiPass]);
$participant = new ParticipantDto();
$participant->mutable = true;
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking());
$this->assertSame(60, $participant->skiPass?->id);
$this->assertSame([], $dropped);
}
public function testDraftRestoresOnRequestSkiPassTheParticipantAlreadyHolds(): void
{
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST);
$travel = $this->createTravel([60 => $skiPass]);
$held = clone $skiPass;
$held->mapping = [1];
$booking = new Booking();
$booking->additionalServices = [60 => $held];
$participant = new ParticipantDto();
$participant->mutable = true;
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], $booking);
$this->assertSame(60, $participant->skiPass?->id);
$this->assertSame([], $dropped);
}
public function testDraftDropsOnlyTheUnbookableEntryOfAMultiSelectField(): void
{
$bookable = $this->createCourse(70, 'Snowboardkurs');
$onRequest = $this->createCourse(71, 'Skikurs', Constants::STATUS_ON_REQUEST);
$travel = $this->createTravel([70 => $bookable, 71 => $onRequest]);
$participant = new ParticipantDto();
$participant->mutable = true;
$dropped = $this->apply($travel, $participant, ['courses' => [70, 71]], new Booking());
$this->assertSame([70], array_map(static fn (Service $s) => $s->id, $participant->courses));
$this->assertSame(['Skikurs'], $dropped);
}
/**
* @param array<string, mixed> $services
*
* @return list<string>
*/
private function apply(Travel $travel, ParticipantDto $participant, array $services, Booking $booking): array
{
$dto = new BookingDto($travel, 1);
$dto->participants = [1 => $participant];
// A non-null booking is what puts the DTO into edit mode
$dto->booking = $booking;
return $this->merger->apply($dto, 1, $participant, ['services' => $services], $travel);
}
/** @param array<int, Service> $additionalServices */
private function createTravel(array $additionalServices): Travel
{
$travel = new Travel();
$travel->additionalServices = $additionalServices;
$travel->additionalServicesMutable = true;
return $travel;
}
private function createSkiPass(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service
{
return $this->createService($id, $label, Constants::TOKEN_SKI_PASS, $status);
}
private function createCourse(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service
{
return $this->createService($id, $label, Constants::TOKEN_COURSES, $status);
}
private function createService(int $id, string $label, string $subType, string $status): Service
{
$service = new Service();
$service->id = $id;
$service->label = $label;
$service->subType = $subType;
$service->status = $status;
$service->category = Constants::CATEGORY_ADDITIONAL;
return $service;
}
}
+84 -3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Service; namespace App\Tests\Service;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Pickup;
@@ -12,6 +13,7 @@ use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Service\BookingEditSubmitGuard; use App\Service\BookingEditSubmitGuard;
use App\Service\ServiceAvailabilityCalculator;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -54,7 +56,7 @@ class BookingEditSubmitGuardTest extends TestCase
->method('createBookingDtoFromBooking') ->method('createBookingDtoFromBooking')
->willReturn($baselineDto); ->willReturn($baselineDto);
$service = new BookingEditSubmitGuard($processor); $service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$changed = $service->reconcileImmutableCategories($workingDto, new Booking()); $changed = $service->reconcileImmutableCategories($workingDto, new Booking());
@@ -91,7 +93,7 @@ class BookingEditSubmitGuardTest extends TestCase
->method('createBookingDtoFromBooking') ->method('createBookingDtoFromBooking')
->willReturn($baselineDto); ->willReturn($baselineDto);
$service = new BookingEditSubmitGuard($processor); $service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$changed = $service->reconcileImmutableCategories($workingDto, new Booking()); $changed = $service->reconcileImmutableCategories($workingDto, new Booking());
@@ -128,7 +130,7 @@ class BookingEditSubmitGuardTest extends TestCase
->method('createBookingDtoFromBooking') ->method('createBookingDtoFromBooking')
->willReturn($baselineDto); ->willReturn($baselineDto);
$service = new BookingEditSubmitGuard($processor); $service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$changed = $service->reconcileImmutableCategories($workingDto, new Booking()); $changed = $service->reconcileImmutableCategories($workingDto, new Booking());
@@ -136,6 +138,85 @@ class BookingEditSubmitGuardTest extends TestCase
$this->assertSame([10], array_map(static fn (Service $s) => $s->id, $workingParticipant->rentals)); $this->assertSame([10], array_map(static fn (Service $s) => $s->id, $workingParticipant->rentals));
} }
public function testRevertUnbookableServiceAdditionsDropsOnRequestAddition(): void
{
$onRequest = $this->createService(99);
$onRequest->label = 'Bus-Hinfahrt';
$onRequest->subType = Constants::TOKEN_SKI_PASS;
$onRequest->status = Constants::STATUS_ON_REQUEST;
$travel = new Travel();
$travel->additionalServices = [99 => $onRequest];
$workingParticipant = new ParticipantDto();
$workingParticipant->index = 0;
$workingParticipant->skiPass = $onRequest;
$freshBooking = new Booking();
$workingDto = new BookingDto($travel, 1);
$workingDto->participants = [$workingParticipant];
$workingDto->booking = $freshBooking;
$baselineParticipant = new ParticipantDto();
$baselineParticipant->index = 0;
$baselineDto = new BookingDto($travel, 1);
$baselineDto->participants = [$baselineParticipant];
$processor = $this->createStub(BookingDataProcessor::class);
$processor->method('createBookingDtoFromBooking')->willReturn($baselineDto);
$guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking);
$this->assertSame(['Bus-Hinfahrt'], $reverted);
$this->assertNull($workingParticipant->skiPass);
}
public function testRevertUnbookableServiceAdditionsKeepsAServiceTheParticipantAlreadyHolds(): void
{
$onRequest = $this->createService(99);
$onRequest->label = 'Bus-Hinfahrt';
$onRequest->subType = Constants::TOKEN_SKI_PASS;
$onRequest->status = Constants::STATUS_ON_REQUEST;
$travel = new Travel();
$travel->additionalServices = [99 => $onRequest];
$held = clone $onRequest;
$held->mapping = [0];
$freshBooking = new Booking();
$freshBooking->additionalServices = [99 => $held];
$workingParticipant = new ParticipantDto();
$workingParticipant->index = 0;
$workingParticipant->skiPass = $onRequest;
$workingDto = new BookingDto($travel, 1);
$workingDto->participants = [$workingParticipant];
$workingDto->booking = $freshBooking;
$baselineParticipant = new ParticipantDto();
$baselineParticipant->index = 0;
$baselineParticipant->skiPass = $held;
$baselineDto = new BookingDto($travel, 1);
$baselineDto->participants = [$baselineParticipant];
$processor = $this->createStub(BookingDataProcessor::class);
$processor->method('createBookingDtoFromBooking')->willReturn($baselineDto);
$guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking);
$this->assertSame([], $reverted);
$this->assertSame($onRequest, $workingParticipant->skiPass);
}
private function createService(int $id): Service private function createService(int $id): Service
{ {
$service = new Service(); $service = new Service();
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service; namespace App\Tests\Service;
use App\BusProNet\Constants; use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Service; use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
@@ -85,6 +86,95 @@ class ServiceAvailabilityCalculatorTest extends TestCase
$this->assertSame([1, 4], array_keys($filtered)); $this->assertSame([1, 4], array_keys($filtered));
} }
public function testOnRequestServiceStaysAvailableInCreateMode(): void
{
$skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createBookingDtoWithServices([$skiPass]);
// Create may still move the whole booking to status 'A', so on request is bookable there
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testOnRequestServiceIsUnavailableInEditMode(): void
{
$skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createEditBookingDtoWithServices([$skiPass]);
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testOnRequestServiceAlreadyHeldStaysAvailableInEditMode(): void
{
$skiPass = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createEditBookingDtoWithServices([$skiPass], heldServiceIds: [1]);
// Withdrawing a booked service breaks the Leistung/Teilnehmer counts, so it must be keepable
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testAutoBookedOnRequestServiceStaysAvailableInEditMode(): void
{
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$service->autoBook = true;
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testMandatoryNonTransportOnRequestServiceStaysAvailableInEditMode(): void
{
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$service->mandatory = true;
$service->category = Constants::CATEGORY_ADDITIONAL;
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
// Blocking a service the mandatory-services validator requires makes the form unsatisfiable
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testMandatoryTransportOnRequestServiceIsStillUnavailableInEditMode(): void
{
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$service->mandatory = true;
$service->category = Constants::CATEGORY_TRANSPORTATION;
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
// pflicht on a transport leg means "pick one of this group", not "compulsory"
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testFilterAvailableServicesNeverEmptiesAGroupOnTheOnRequestRuleAlone(): void
{
$first = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$second = $this->createSkiPass(id: 2, available: 10, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createEditBookingDtoWithServices([$first, $second]);
$filtered = $this->calculator->filterAvailableServices(
$bookingDto->travel->additionalServices,
$bookingDto,
0
);
$this->assertSame([1, 2], array_keys($filtered));
}
public function testFilterAvailableServicesStillEmptiesAGroupThatIsSoldOutOrBlocked(): void
{
$soldOut = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST);
$blocked = $this->createSkiPass(id: 2, available: null, status: Constants::STATUS_BLOCKED);
$bookingDto = $this->createEditBookingDtoWithServices([$soldOut, $blocked]);
$filtered = $this->calculator->filterAvailableServices(
$bookingDto->travel->additionalServices,
$bookingDto,
0
);
$this->assertSame([], array_keys($filtered));
}
private function createParkingService(int $id, int $available): Service private function createParkingService(int $id, int $available): Service
{ {
$service = new Service(); $service = new Service();
@@ -123,4 +213,27 @@ class ServiceAvailabilityCalculatorTest extends TestCase
return $bookingDto; return $bookingDto;
} }
/**
* @param array<int, Service> $services
* @param list<int> $heldServiceIds IDs participant 0 already holds in the booking
*/
private function createEditBookingDtoWithServices(array $services, array $heldServiceIds = []): BookingDto
{
$bookingDto = $this->createBookingDtoWithServices($services);
$heldServices = [];
foreach ($heldServiceIds as $serviceId) {
$held = clone $bookingDto->travel->additionalServices[$serviceId];
$held->mapping = [0];
$heldServices[$serviceId] = $held;
}
// A non-null booking is what puts the DTO into edit mode
$booking = new Booking();
$booking->additionalServices = $heldServices;
$bookingDto->booking = $booking;
return $bookingDto;
}
} }
+3 -3
View File
@@ -13,7 +13,7 @@ class AppRuntimeRoleLabelsTest extends TestCase
public function testEffectiveRolesAreLabelled(): void public function testEffectiveRolesAreLabelled(): void
{ {
self::assertSame( self::assertSame(
['Teamer:in', 'Preisrechner'], ['Teamer:in', 'GRO-Expert:in'],
$this->runtime()->effectiveRoles([Role::TEAMER, Role::GROUPS_MANAGER]), $this->runtime()->effectiveRoles([Role::TEAMER, Role::GROUPS_MANAGER]),
); );
} }
@@ -26,10 +26,10 @@ class AppRuntimeRoleLabelsTest extends TestCase
public function testNominationsAreListedApartFromTheEffectiveRoles(): void public function testNominationsAreListedApartFromTheEffectiveRoles(): void
{ {
$roles = [Role::TEAMER, Role::pending(Role::ADMIN)]; $roles = [Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)];
self::assertSame(['Teamer:in'], $this->runtime()->effectiveRoles($roles)); self::assertSame(['Teamer:in'], $this->runtime()->effectiveRoles($roles));
self::assertSame(['Administration'], $this->runtime()->nominatedRoles($roles)); self::assertSame(['Hausleitung'], $this->runtime()->nominatedRoles($roles));
} }
public function testUnknownRoleStaysVisible(): void public function testUnknownRoleStaysVisible(): void