Compare commits

..
Author SHA1 Message Date
frommeandClaude Opus 5 3221008f90 refactor: move the bulk export to its own /api/exports resource
/api/travels/{dateCode} accepts an arbitrary string, so /api/travels/export
shared a namespace with every date code and stayed reachable only because
byCode() carried a negative lookahead ruling that one string out. Nothing
in the URL announced the constraint, and getting it wrong mis-routed
silently: the first attempt widened the segment to `.+` and swallowed
/api/travels/{dateId}/{hotelId}/{dateTo}/availability whole.

/api/exports never takes a parameterised segment, the same shape as
/api/contingents, so the collision stops existing instead of being
guarded. TravelController returns byte-for-byte to its state on master,
lookahead included, and the export moves to its own resource controller.

The routing test now also asserts that nothing is reintroduced under the
date code namespace.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 14:23:36 +02:00
frommeandClaude Opus 5 b27639c7fa fix: keep the travel date code to a single path segment
The dateCode requirement added with the export route was `.+`, which
replaces Symfony's default `[^/]+` and lets the segment span slashes.
`/api/travels/{dateId}/{hotelId}/{dateTo}/availability` was then matched
by byCode() -- declared above it -- with dateCode swallowing all four
segments, so availability answered 404 "Not found" instead of reaching
the upstream call.

Adds a routing test that reflects the controller's own attributes, so
both the declaration order and the segment class stay guarded. Neither
fails loudly on its own: a path falling through to the wrong route reads
like an endpoint that exists and holds no data.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 14:19:24 +02:00
frommeandClaude Opus 5 ca1ab17595 docs: document the travel export endpoint and the new hotel fields
Also corrects the token TTL: the guide claimed one hour for
client_credentials, which stopped being true when the tagged grant
service was dropped. Every grant now issues a PT10M token.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:14:59 +02:00
frommeandClaude Opus 5 c5685e6d7c feat: add paginated travel export endpoint for the teamer app
GET /api/travels/export flattens the XML export into one row per travel
date x hotel with its pickups resolved, so the Teamer app can import the
disposition in one walk instead of fanning out over /api/travels.

Built from the XML file map, not travel_snapshot: the snapshot table sits
at this grain but refreshes on its own schedule, while the loaders share
the bpn.cache pool under the xml-sync tag. That is what makes /api/hotels,
/api/pickups and this export move together for free.

Each Ziel_*.xml is walked once and TravelParser::getPickups() does the
zeit parsing, which buys both zeit shapes and adjustOvernightTimes() --
a time-only 02:15 listed after a 23:59 rolls onto the next calendar day.
Pickups join against PickupLoader::loadAll() rather than
patchPickupsDetails(), which sorts: the contract is source order.

The whole assembled export is cached, not a page, so skipped, total and
pickupsWithoutMasterData are whole-export figures identical on every page
and a consumer can abort after the first one. Errors stay real status
codes; the builder is deliberately not wrapped in a catch that would
answer 200 with an error body the way hotelAvailability() does.

The route is declared before api_travel_single_code and that route grows
a requirement, or /api/travels/export resolves to byCode('export') and
404s as though the endpoint existed with no data.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:13:55 +02:00
frommeandClaude Opus 5 0a5602fd41 fix: skip travels whose hotel is missing from the master data
generateFilesMap() read ->name straight off $hotels[$hotelId] with no
guard, so a travel referencing a hotel absent from hotel.xml produced an
undefined-key warning and an entry with a null label -- a hotel in
/api/travels that resolves nowhere. No row hits this in the current
snapshot, which is why it has never surfaced.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:08:07 +02:00
frommeandClaude Opus 5 f3745f918e feat: expose hotel cost unit and iso country code via the api
The Teamer app groups hotels by cost unit and renders a flag per
destination, and cannot derive either from what /api/hotels returns
today.

costUnit is the raw kostenstelle and stays a string: only 281 of 1931
hotels carry one and some hold a space-separated list such as "84 85".

countryCode goes through CountryCodeMapper, whose table is part of the
API contract: BusPro's land column mixes ISO-2, ISO-3 and European
vehicle registration codes for the same country, and a wrong entry
mislabels a destination's flag silently. An unmappable token is null and
warns on the bpn channel, never a guess. country itself is untouched --
it remains the join key into /api/countries[].token.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:07:13 +02:00
frommeandClaude Opus 5 65c1a7bd12 feat: enable client credentials grant for machine-to-machine api clients
The Teamer app imports travel and hotel master data nightly with no user
in the loop. AuthorizationCodeListener is bound to
AUTHORIZATION_REQUEST_RESOLVE, which only the auth code grant dispatches,
so a userless token never reaches the required_roles gate.

Drops the tagged grant service added in 79709e3a. That tag registered
client_credentials through the bundle's @deprecated grant configurator,
which runs regardless of enable_client_credentials_grant and re-enabled
the grant a second time with its own PT1H. Two enableGrantType() calls
for one grant left the lifetime decided by call order. M2M tokens now use
the shared access_token_ttl of PT10M.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-29 12:04:05 +02:00
fromme d0e7ba017c fix: resync room selections with travel data before step 2 2026-09-29 10:59:13 +02:00
fromme be0e15b3d4 chore: update project dependencies 2026-09-29 10:12:41 +02:00
fromme f5e6d2105d feat: make boarding point price levelling switchable 2026-09-29 10:11:03 +02:00
fromme 0c6126010f fix: always adopt outbound pickup price for inbound when set 2026-09-28 16:33:36 +02:00
fromme 29f295abf6 feat: duplicate an accommodation booking into a new draft
addresses #869f6z44f
2026-09-28 08:55:01 +02:00
fromme 2aa98b81fe feat: dedicated login page for oauth2 logins 2026-09-27 13:43:17 +02:00
fromme 56adf72d12 fix: refuse implausible accommodation booking prices 2026-09-26 15:39:04 +02:00
fromme 773cd4f847 fix: keep post-booking newsletter failures out of the booking flow 2026-09-26 14:00:26 +02:00
fromme 876e69e62c feat: offer a newsletter opt-in for the other participants
addresses #869etn6c5
2026-09-26 13:53:10 +02:00
fromme 96b7b3f451 fix: anonymize user first and last names 2026-09-23 17:22:19 +02:00
fromme e751eed4a0 chore: update project dependencies 2026-09-23 16:53:49 +02:00
fromme bad916cfb7 feat: identify accounts by a uuid in the oauth2 sub claim 2026-09-23 15:17:09 +02:00
fromme cc3d32fcbb feat: gate oauth2 authorization on per-client roles 2026-09-23 13:46:56 +02:00
fromme 79709e3a3e feat: give client_credentials tokens a one-hour lifetime 2026-09-22 16:56:06 +02:00
fromme ab7d00b35d feat: emit team admin role in userinfo claims when applicable 2026-09-21 11:43:36 +02:00
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
95 changed files with 5402 additions and 473 deletions

No files matched your search

+9
View File
@@ -92,6 +92,15 @@ APP_TRAVEL_PREFER_REMOTE=false
APP_TRAVEL_ENABLE_FALLBACK=true APP_TRAVEL_ENABLE_FALLBACK=true
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14 APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
# Compensates a BusPro export defect: a <zustieg_rueck> can omit its preis although the booking
# engine still bills the return-leg Abfahrtsortzuschlag, so we undercharge by exactly one leg.
# A boarding point costs the same in both directions, so the stated price is applied to both.
# Off = report the export verbatim. Provisional until the vendor fixes the export; see
# docs/buspro-database-reference.md section A7.
# Flipping this needs `app:bpn:xml-cache-invalidate` and `app:bpn:refresh-travel-snapshot`,
# because the cached travels and TravelSnapshot rows carry the parsed price.
APP_BPN_LEVEL_BOARDING_POINT_PRICES=true
# Emails # Emails
APP_DEFAULT_EMAIL_FROM=[email protected] APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected] APP_DEFAULT_EMAIL_TO=[email protected]
Generated
+115 -256
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",
@@ -1115,16 +1113,16 @@
}, },
{ {
"name": "doctrine/orm", "name": "doctrine/orm",
"version": "3.7.1", "version": "3.7.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/orm.git", "url": "https://github.com/doctrine/orm.git",
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe" "reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/orm/zipball/037807a39697890dcede8b766c5efe8a1b4855fe", "url": "https://api.github.com/repos/doctrine/orm/zipball/6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe", "reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1148,7 +1146,7 @@
"doctrine/coding-standard": "^14.0", "doctrine/coding-standard": "^14.0",
"phpbench/phpbench": "^1.0", "phpbench/phpbench": "^1.0",
"phpstan/extension-installer": "^1.4", "phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "2.1.23", "phpstan/phpstan": "2.2.14",
"phpstan/phpstan-deprecation-rules": "^2", "phpstan/phpstan-deprecation-rules": "^2",
"phpunit/phpunit": "^10.5.0 || ^11.5", "phpunit/phpunit": "^10.5.0 || ^11.5",
"psr/log": "^1 || ^2 || ^3", "psr/log": "^1 || ^2 || ^3",
@@ -1199,9 +1197,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/doctrine/orm/issues", "issues": "https://github.com/doctrine/orm/issues",
"source": "https://github.com/doctrine/orm/tree/3.7.1" "source": "https://github.com/doctrine/orm/tree/3.7.2"
}, },
"time": "2026-09-14T05:45:59+00:00" "time": "2026-09-23T05:28:00+00:00"
}, },
{ {
"name": "doctrine/persistence", "name": "doctrine/persistence",
@@ -3374,16 +3372,16 @@
}, },
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.14.0", "version": "3.14.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/CarbonPHP/carbon.git", "url": "https://github.com/CarbonPHP/carbon.git",
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2" "reference": "34e9109535165bf1d7ff17a610492372ae73a896"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2", "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/34e9109535165bf1d7ff17a610492372ae73a896",
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2", "reference": "34e9109535165bf1d7ff17a610492372ae73a896",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3475,7 +3473,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-09-12T20:45:19+00:00" "time": "2026-09-27T12:07:26+00:00"
}, },
{ {
"name": "nyholm/psr7", "name": "nyholm/psr7",
@@ -3676,20 +3674,25 @@
}, },
{ {
"name": "phpdocumentor/reflection-common", "name": "phpdocumentor/reflection-common",
"version": "2.2.0", "version": "2.2.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpDocumentor/ReflectionCommon.git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/ee15a5a2022c8383906b225055e9496a6a5e6e3b",
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2 || ^8.0" "php": "^7.4 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.8",
"phpunit/phpunit": "^9.5",
"vimeo/psalm": "^4.25"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -3713,7 +3716,7 @@
} }
], ],
"description": "Common reflection classes used by phpdocumentor to reflect the code structure", "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
"homepage": "http://www.phpdoc.org", "homepage": "https://www.phpdoc.org",
"keywords": [ "keywords": [
"FQSEN", "FQSEN",
"phpDocumentor", "phpDocumentor",
@@ -3723,9 +3726,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
"source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.2.1"
}, },
"time": "2020-06-27T09:03:43+00:00" "time": "2025-11-10T21:21:55+00:00"
}, },
{ {
"name": "phpdocumentor/reflection-docblock", "name": "phpdocumentor/reflection-docblock",
@@ -4072,16 +4075,16 @@
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.3.5", "version": "2.3.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git", "url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" "reference": "1427af4647235b4a73b13940b1f1258d3584f36e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e",
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "reference": "1427af4647235b4a73b13940b1f1258d3584f36e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4113,9 +4116,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types", "description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": { "support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues", "issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6"
}, },
"time": "2026-08-31T16:05:28+00:00" "time": "2026-09-27T20:07:45+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",
@@ -4697,35 +4700,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"
@@ -4733,7 +4734,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": {
@@ -4771,9 +4772,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",
@@ -11210,161 +11211,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": {
@@ -11413,7 +11271,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": [
{ {
@@ -11425,20 +11283,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": {
@@ -11481,7 +11339,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": [
{ {
@@ -11493,20 +11351,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.30.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": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025"
}, },
"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/211c603b3f7d5a8f6c6543a02fe9f82c09b18025",
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", "reference": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11545,7 +11403,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.30.0"
}, },
"funding": [ "funding": [
{ {
@@ -11557,7 +11415,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-19T20:44:48+00:00" "time": "2026-09-25T05:50:11+00:00"
}, },
{ {
"name": "twig/string-extra", "name": "twig/string-extra",
@@ -11628,16 +11486,16 @@
}, },
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.28.0", "version": "v3.30.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/Twig.git", "url": "https://github.com/twigphp/Twig.git",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef"
}, },
"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/8c737079b726af72ff8ef3c595be9f6a810ea1ef",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11692,7 +11550,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.30.0"
}, },
"funding": [ "funding": [
{ {
@@ -11704,7 +11562,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-07-03T20:44:34+00:00" "time": "2026-09-25T13:20:01+00:00"
}, },
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
@@ -11923,16 +11781,16 @@
}, },
{ {
"name": "composer/semver", "name": "composer/semver",
"version": "3.4.4", "version": "3.5.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/composer/semver.git", "url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11974,7 +11832,7 @@
"homepage": "http://robbast.nl" "homepage": "http://robbast.nl"
} }
], ],
"description": "Semver library that offers utilities, version constraint parsing and validation.", "description": "Version comparison library that offers utilities, version constraint parsing and validation.",
"keywords": [ "keywords": [
"semantic", "semantic",
"semver", "semver",
@@ -11984,7 +11842,7 @@
"support": { "support": {
"irc": "ircs://irc.libera.chat:6697/composer", "irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues", "issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4" "source": "https://github.com/composer/semver/tree/3.5.0"
}, },
"funding": [ "funding": [
{ {
@@ -11996,7 +11854,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-08-20T19:15:30+00:00" "time": "2026-09-24T14:38:51+00:00"
}, },
{ {
"name": "composer/xdebug-handler", "name": "composer/xdebug-handler",
@@ -12236,23 +12094,23 @@
}, },
{ {
"name": "fidry/cpu-core-counter", "name": "fidry/cpu-core-counter",
"version": "1.3.0", "version": "1.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/theofidry/cpu-core-counter.git", "url": "https://github.com/theofidry/cpu-core-counter.git",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678" "reference": "7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d",
"reference": "db9508f7b1474469d9d3c53b86f817e344732678", "reference": "7cbeb03e286fa81b2bbe0e6bd6b8a27c71a3144d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2 || ^8.0" "php": "^7.2 || ^8.0"
}, },
"require-dev": { "require-dev": {
"fidry/makefile": "^0.2.0", "fidry/makefile": "^0.2.0 || ^1.1.2",
"fidry/php-cs-fixer-config": "^1.1.2", "fidry/php-cs-fixer-config": "^1.1.2",
"phpstan/extension-installer": "^1.2.0", "phpstan/extension-installer": "^1.2.0",
"phpstan/phpstan": "^2.0", "phpstan/phpstan": "^2.0",
@@ -12260,6 +12118,7 @@
"phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^8.5.31 || ^9.5.26", "phpunit/phpunit": "^8.5.31 || ^9.5.26",
"symfony/polyfill-mbstring": "^1.33",
"webmozarts/strict-phpunit": "^7.5" "webmozarts/strict-phpunit": "^7.5"
}, },
"type": "library", "type": "library",
@@ -12285,7 +12144,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/theofidry/cpu-core-counter/issues", "issues": "https://github.com/theofidry/cpu-core-counter/issues",
"source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.0"
}, },
"funding": [ "funding": [
{ {
@@ -12293,20 +12152,20 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-08-14T07:29:31+00:00" "time": "2026-09-27T17:40:20+00:00"
}, },
{ {
"name": "friendsofphp/php-cs-fixer", "name": "friendsofphp/php-cs-fixer",
"version": "v3.95.25", "version": "v3.95.27",
"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": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317"
}, },
"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/1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", "reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12340,15 +12199,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",
@@ -12389,7 +12248,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.27"
}, },
"funding": [ "funding": [
{ {
@@ -12397,7 +12256,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-09-08T11:11:37+00:00" "time": "2026-09-22T09:54:55+00:00"
}, },
{ {
"name": "myclabs/deep-copy", "name": "myclabs/deep-copy",
@@ -12636,11 +12495,11 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.2.14", "version": "2.2.16",
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92",
"reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12696,7 +12555,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-09-12T21:39:33+00:00" "time": "2026-09-25T09:31:51+00:00"
}, },
{ {
"name": "phpstan/phpstan-doctrine", "name": "phpstan/phpstan-doctrine",
@@ -13196,16 +13055,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "12.5.35", "version": "12.5.36",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab" "reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab", "reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -13274,7 +13133,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.35" "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.36"
}, },
"funding": [ "funding": [
{ {
@@ -13282,7 +13141,7 @@
"type": "other" "type": "other"
} }
], ],
"time": "2026-09-09T04:48:50+00:00" "time": "2026-09-25T08:47:40+00:00"
}, },
{ {
"name": "react/cache", "name": "react/cache",
@@ -19,6 +19,9 @@ league_oauth2_server:
doctrine: null doctrine: null
client: client:
allow_plaintext_secrets: false allow_plaintext_secrets: false
# App\Entity\OAuth2Client adds the required_roles column that
# App\EventListener\AuthorizationCodeListener gates authorization on.
classname: App\Entity\OAuth2Client
when@test: when@test:
league_oauth2_server: league_oauth2_server:
+13
View File
@@ -185,6 +185,10 @@ services:
arguments: arguments:
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%' $retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
App\BusProNet\XmlParser\TravelParser:
arguments:
$levelBoardingPointPrices: '%env(bool:APP_BPN_LEVEL_BOARDING_POINT_PRICES)%'
App\Service\TravelDataProvider: App\Service\TravelDataProvider:
arguments: arguments:
$logger: '@monolog.logger.core' $logger: '@monolog.logger.core'
@@ -203,6 +207,10 @@ services:
arguments: arguments:
$cache: '@bpn.cache' $cache: '@bpn.cache'
App\BusProNet\Utility\CountryCodeMapper:
arguments:
$logger: '@monolog.logger.bpn'
App\BusProNet\XmlLoader\HotelLoader: App\BusProNet\XmlLoader\HotelLoader:
arguments: arguments:
$cache: '@bpn.cache' $cache: '@bpn.cache'
@@ -215,6 +223,11 @@ services:
arguments: arguments:
$cache: '@bpn.cache' $cache: '@bpn.cache'
App\Service\TravelExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator: App\Service\VoucherValidator:
arguments: arguments:
$cache: '@cache.app' $cache: '@cache.app'
+128 -8
View File
@@ -10,7 +10,10 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
- Token endpoint: `POST /token` (unauthenticated). - Token endpoint: `POST /token` (unauthenticated).
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled. - Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
- Access token TTL: **10 minutes** — clients must refresh/re-request frequently. - Access token TTL is **10 minutes** for every grant.
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
A token can therefore expire part-way through a long page walk of `GET /api/exports/travels`; re-request one on
401 and resume at the page you were on. At the default `perPage=500` the whole export is a single page today.
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`. - Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`). - Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
- Send the token as `Authorization: Bearer <access_token>`. - Send the token as `Authorization: Bearer <access_token>`.
@@ -47,7 +50,9 @@ There is no single error envelope; three shapes occur:
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code. - `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter). - `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**. Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
`GET /api/exports/travels` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them. The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
@@ -59,11 +64,23 @@ The HTTP methods listed below are the intended ones. Several read routes do not
#### `GET /api/hotels` — scope `api` #### `GET /api/hotels` — scope `api`
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`. List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`,
`costUnit`, `countryCode`.
- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`.
It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and
ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country.
- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick
a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as
"unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until
the mapping table is extended.
- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have
none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it.
#### `GET /api/hotels/{id}` — scope `api` #### `GET /api/hotels/{id}` — scope `api`
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown. Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`.
`404 {"message":"Not found"}` if unknown.
#### `GET /api/countries` — scope `api` #### `GET /api/countries` — scope `api`
@@ -122,6 +139,107 @@ This is a full overwrite, not a merge.
--- ---
### Bulk exports
#### `GET /api/exports/travels` — scope `api`
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
This is the only paginated endpoint and the only one with a `meta` envelope.
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
arbitrary string, so an export hung off that collection would share a namespace with every date code.
`/api/exports` never takes a parameterised segment, so there is nothing for it to collide with.
Query parameters:
| Parameter | Type | Default | Notes |
|---|---|---|---|
| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below |
| `perPage` | int 1–1000 | `500` | |
| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. |
A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send
`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not*
an error: it returns `200` with an empty `data` array and the usual `meta`.
```json
{
"data": [
{
"busProId": 12794,
"code": "PCJSER261226",
"product": "Semesterende - Portes du Soleil - Joliment",
"dateFrom": "2026-12-26",
"dateTo": "2026-12-30",
"hotel": {
"busProId": 225444,
"code": "PCJSER1",
"name": "NEU SER Jolimont 1",
"costUnit": "863",
"country": "CH",
"countryCode": "ch"
},
"pickups": [
{
"busProId": 18,
"code": "ESS-Route",
"city": "Essen",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"time": "20:15",
"datetime": "2026-12-25T20:15:00+01:00"
}
]
}
],
"meta": {
"page": 1,
"perPage": 500,
"total": 415,
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"generatedAt": "2026-09-29T12:10:06+02:00"
}
}
```
**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId`
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
empty, never null.
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`
and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have
lost rows you previously had.
- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from
`GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point
with no name is more useful to a disposition view than one that silently vanished.
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
- `time` is `"HH:MM"` for display; `datetime` is the same moment as a full ISO-8601 string with a
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
bare-time sources too, and carries that resolution.
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
null together.
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a
day earlier than the running order implies. This is upstream data, not a transformation applied here.
**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`,
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
---
### Travels ### Travels
#### `GET /api/travels` — scope `api` #### `GET /api/travels` — scope `api`
@@ -379,7 +497,7 @@ 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: `sub` (string), `email` - always: `sub` (UUID string, see below), `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/buspronet-schema/crm-selection-queries.md#from-claim-to-role`) - 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:
@@ -406,8 +524,10 @@ All of those accounts report the **same** `person_id` and `address_id`, and the
`profile.communication.email` — that value is the first contact address on the BusPro record and `profile.communication.email` — that value is the first contact address on the BusPro record and
is **not necessarily** the one signed in with. So: is **not necessarily** the one signed in with. So:
- **`sub`** — opaque, stable, unique per MyE&P account. The only claim that identifies an account. - **`sub`** — a **UUID (v4, RFC 4122, 36 characters)**, stable for the life of the account and
Match your local user on it and store it. unique across accounts. The only claim that identifies an account. Match your local user on it
and store it. Size the column at 36 characters and treat the value as opaque: it is random, it
carries no timestamp and no ordering, and nothing about it should be parsed or derived from.
- **`email`** — the address this session actually authenticated with. Distinct per account, but - **`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. 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_id` / `address_id`** — the BusPro person behind the account. Shared between that
@@ -427,7 +547,7 @@ CRM selection attributes/segments for the authenticated user. `400 {"message": "
## Client implementation notes ## Client implementation notes
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401. 1. **Token lifetime comes from `expires_in`** (currently 1 hour for client_credentials, 10 minutes for authorization_code) — cache the token for that long, refresh with a safety margin, and retry once on 401.
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint. 2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary. 3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries. 4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
+11
View File
@@ -225,6 +225,17 @@ JOIN dbo.ZustiegPreis zp ON zp.IDArtZustiegPreis_FS=lb.IDArtZustiegPreis_FS
WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId); WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId);
``` ```
**V 2026-09-28 — the XML export can omit the return-leg price.** Since one price row is
surfaced twice, an asymmetric pair is an export defect: on products 701 and 2435 a
`<zustieg_rueck>` carries no `preis` although the engine still bills the return
`Abfahrtsortzuschlag`, so reading the export verbatim undercharges by one leg. Measured over
all exports, a stated return price never differs from its outbound counterpart (510 pairs,
zero counter-examples). `TravelParser` therefore levels a boarding point's price across both
directions, switchable through `APP_BPN_LEVEL_BOARDING_POINT_PRICES` while the defect is open
with the vendor. Flipping the flag needs `app:bpn:xml-cache-invalidate` and
`app:bpn:refresh-travel-snapshot`. A point appearing **only** in `zustiege_rueck` without a
price cannot be priced from our side — that is a catalog job.
**N — the departure surcharge is computed.** Booking responses carry a position **N — the departure surcharge is computed.** Booking responses carry a position
`art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag` `art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag`
— the lookup behind `BuchungTNZuschlag` — holds only discounts and fees — the lookup behind `BuchungTNZuschlag` — holds only discounts and fees
@@ -18,11 +18,12 @@ Symfony roles:
| `IDSelektionsstamm` | Role | | `IDSelektionsstamm` | Role |
| --- | --- | | --- | --- |
| `1070` | `ROLE_TEAMER` | | `1070` | `ROLE_TEAMER` |
| `1292` | `ROLE_ADMIN` (+ `ROLE_HOUSE_MANAGER`, + hotel code `SSL`) | | `1292` | `ROLE_ADMIN` |
| `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` | | `1483` | `ROLE_CUSTOMER_EXPERT` |
| `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) |
| 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` |
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the per-client role requirement that App\EventListener\AuthorizationCodeListener
* gates the authorization code grant on.
*
* The column defaults to an empty list and an empty list denies, so every client is
* refused until its roles are set. The two clients in actual use are therefore set here
* rather than by hand after the deploy: it closes the window in which both staff tools
* would be live but unable to authorize anyone, and it keeps the role strings somewhere
* review can see them.
*
* Matched on name, not identifier: client identifiers are generated per environment and
* differ between local, staging and production, while the names do not.
*
* Deliberately left empty, and so denied: myepapp (the mobile app) and myep (the Keycloak
* broker), both R&D only and not in use. The client_credentials clients never reach the
* authorization endpoint and are unaffected either way.
*/
final class Version20260923120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds oauth2_client.required_roles and sets it for the two staff clients.';
}
public function up(Schema $schema): void
{
// Added nullable and backfilled before being tightened: a NOT NULL column with no
// default leaves existing rows holding an empty string, which is not valid JSON and
// fails to hydrate -- the row cannot even reach the listener that would deny it.
$this->addSql("ALTER TABLE oauth2_client ADD required_roles JSON DEFAULT NULL COMMENT '(DC2Type:json)'");
$this->addSql("UPDATE oauth2_client SET required_roles = '[]'");
$this->addSql("ALTER TABLE oauth2_client MODIFY required_roles JSON NOT NULL COMMENT '(DC2Type:json)'");
// mirrors ephub's App\Security\Role::ELIGIBLE
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
'roles' => json_encode(['ROLE_EMPLOYEE', 'ROLE_ADMIN'], JSON_THROW_ON_ERROR),
'name' => 'EPHub',
]);
// mirrors myep-team's App\Security\MyEpAuthenticator::ELIGIBLE_ROLES
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
'roles' => json_encode(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'], JSON_THROW_ON_ERROR),
'name' => 'MyEPTeam',
]);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE oauth2_client DROP required_roles');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Symfony\Component\Uid\Uuid;
/**
* Gives every account a uuid, which replaces the primary key as the OAuth2 `sub` claim.
*
* The UUIDs are generated here in PHP rather than with MariaDB's UUID(): that function
* returns v1, which encodes a timestamp and the server's node id, so a whole backfill
* would come out time-ordered and share a suffix. Guessing one account's identifier from
* another's is the property this column exists to remove, so the backfill has to be as
* random as the ones App\Entity\User generates from now on.
*
* One statement per row, which is why it is emitted rather than written out: the values
* cannot exist before the migration runs. Added nullable and tightened afterwards, since
* there is no single default that could satisfy a unique column.
*/
final class Version20260923150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds user.uuid and backfills it; the OAuth2 sub claim moves off the primary key.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE user ADD uuid VARCHAR(36) DEFAULT NULL');
foreach ($this->connection->fetchFirstColumn('SELECT id FROM user') as $id) {
$this->addSql('UPDATE user SET uuid = :uuid WHERE id = :id', [
'uuid' => Uuid::v4()->toRfc4122(),
'id' => $id,
]);
}
$this->addSql('ALTER TABLE user MODIFY uuid VARCHAR(36) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649D17F50A6 ON user (uuid)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_8D93D649D17F50A6 ON user');
$this->addSql('ALTER TABLE user DROP uuid');
}
}
+11
View File
@@ -35,4 +35,15 @@ class Hotel
#[Groups(['api:list', 'api:single', 'snapshot'])] #[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $country = null; public ?string $country = null;
/**
* Raw `kostenstelle`, deliberately a string: coverage is thin and some hotels carry a
* space-separated list such as "84 85", so it is an opaque label, not a number.
*/
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $costUnit = null;
/** {@see \App\BusProNet\Utility\CountryCodeMapper} — lowercase ISO-2, null when unmappable. */
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $countryCode = null;
} }
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Utility;
use Psr\Log\LoggerInterface;
/**
* Maps BusPro's `hotel/land` token onto a lowercase ISO 3166-1 alpha-2 code.
*
* BusPro has no country vocabulary: the field is free text that has accumulated three
* conventions side by side — ISO-2 (`CH`, `NL`), the European vehicle registration codes
* (`D`, `A`, `F`, `S`, `FL`) and ISO-3 (`AUT`, `PRT`, `HRV`), with the same country often
* under several tokens. The table below is therefore part of the API contract rather than
* a derivation: consumers render a flag from it, and a wrong entry mislabels a destination
* silently, which is worse than no flag at all. An unknown token maps to null — never to a
* guess, never to an empty string.
*/
class CountryCodeMapper
{
/**
* Keys are upper-cased tokens, values lowercase ISO-2.
*
* Entries worth their explanation:
* - `IR` is Ireland, not Iran. Every row carrying it is Dublin or Carlow.
* - `SLO` is Slovakia here, not Slovenia: its only row is a Slovak company, and
* Slovenia already arrives under its own `SI`.
* - `S`, `N`, `FL` are vehicle codes (Sweden, Norway, Liechtenstein), consistent with
* the `D`/`A`/`F`/`I`/`E`/`B` in the same column.
* - `UK` is the colloquial form of the ISO `GB`.
*
* @var array<string, string>
*/
private const MAP = [
'A' => 'at',
'AT' => 'at',
'AUT' => 'at',
'B' => 'be',
'CA' => 'ca',
'CH' => 'ch',
'CZ' => 'cz',
'D' => 'de',
'DEU' => 'de',
'DK' => 'dk',
'E' => 'es',
'ES' => 'es',
'F' => 'fr',
'FI' => 'fi',
'FL' => 'li',
'FR' => 'fr',
'GR' => 'gr',
'GRE' => 'gr',
'HR' => 'hr',
'HRV' => 'hr',
'HU' => 'hu',
'I' => 'it',
'IL' => 'il',
'IR' => 'ie',
'IS' => 'is',
'IT' => 'it',
'LT' => 'lt',
'LV' => 'lv',
'MT' => 'mt',
'N' => 'no',
'NL' => 'nl',
'PL' => 'pl',
'PRT' => 'pt',
'PT' => 'pt',
'RUS' => 'ru',
'S' => 'se',
'SI' => 'si',
'SLO' => 'sk',
'UK' => 'gb',
'USA' => 'us',
'ZA' => 'za',
];
/**
* Tokens already reported in this process, so a master data file full of the same
* unknown token produces one log line rather than one per hotel.
*
* @var array<string, true>
*/
private array $reported = [];
public function __construct(
private readonly LoggerInterface $logger,
) {
}
/**
* @param string|null $token the raw `hotel/land` value
*
* @return string|null lowercase ISO-2, or null when absent, empty or unmappable
*/
public function map(?string $token): ?string
{
if (null === $token) {
return null;
}
$normalized = strtoupper(trim($token));
if ('' === $normalized) {
return null;
}
if (isset(self::MAP[$normalized])) {
return self::MAP[$normalized];
}
if (false === isset($this->reported[$normalized])) {
$this->reported[$normalized] = true;
$this->logger->warning('Unmapped BusPro country token', ['token' => $token]);
}
return null;
}
}
+15
View File
@@ -4,14 +4,25 @@ namespace App\BusProNet\XmlLoader;
use App\BusProNet\Model\Hotel; use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\CountryCodeMapper;
use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlCrawlerFactory;
use App\Exception\HotelNotFoundException; use App\Exception\HotelNotFoundException;
use League\Flysystem\FilesystemOperator;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
class HotelLoader extends AbstractLoader class HotelLoader extends AbstractLoader
{ {
public function __construct(
private readonly CountryCodeMapper $countryCodeMapper,
CacheInterface $cache,
FilesystemOperator $xmlExport,
) {
parent::__construct($cache, $xmlExport);
}
/** @return array<int, Hotel> */ /** @return array<int, Hotel> */
public function loadAll(?string $filename = 'hotel.xml'): array public function loadAll(?string $filename = 'hotel.xml'): array
{ {
@@ -87,6 +98,10 @@ class HotelLoader extends AbstractLoader
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land')); $hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse')); $hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon')); $hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon'));
// String, never int: see Hotel::$costUnit.
$hotel->costUnit = $this->getStringOrNullValue($node->filterXPath('//kostenstelle'));
// `country` itself stays the raw token — it is the join key into /api/countries[].token.
$hotel->countryCode = $this->countryCodeMapper->map($hotel->country);
return $hotel; return $hotel;
} }
+7
View File
@@ -92,6 +92,13 @@ class TravelLoader extends AbstractLoader
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) { ->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
$hotelId = (int) $hotelNode->attr('idbuspro'); $hotelId = (int) $hotelNode->attr('idbuspro');
// A travel may reference a hotel that hotel.xml does not carry.
// Reading ->name off the miss would fatal the whole mapping, not
// just this entry, and take /api/travels down with it.
if (false === isset($hotels[$hotelId])) {
return;
}
$mapping[$travelId]['hotels'][$hotelId] = [ $mapping[$travelId]['hotels'][$hotelId] = [
'id' => $hotelId, 'id' => $hotelId,
'code' => $hotelNode->attr('code'), 'code' => $hotelNode->attr('code'),
@@ -8,6 +8,11 @@ use App\BusProNet\XmlCrawlerFactory;
class ApiResponseParser extends AbstractParser class ApiResponseParser extends AbstractParser
{ {
public function __construct(
private readonly TravelParser $travelParser,
) {
}
/** /**
* @throws ResponseParserException * @throws ResponseParserException
*/ */
@@ -81,7 +86,7 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('No travel data node found in product data response'); throw new ResponseParserException('No travel data node found in product data response');
} }
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs); return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
case ApiClient::TYPE_AGENCIES: case ApiClient::TYPE_AGENCIES:
return (new AgencyParser())->parse($resultNode); return (new AgencyParser())->parse($resultNode);
case ApiClient::TYPE_PURCHASE_VOUCHER: case ApiClient::TYPE_PURCHASE_VOUCHER:
@@ -40,6 +40,7 @@ class CrmAttributesResponseParser
1477 => Role::GROUPS_MANAGER, 1477 => Role::GROUPS_MANAGER,
1478 => Role::GROUPS_ADMIN, 1478 => Role::GROUPS_ADMIN,
1483 => Role::CUSTOMER_EXPERT, 1483 => Role::CUSTOMER_EXPERT,
1484 => Role::TEAM_ADMIN,
]; ];
/** /**
+48 -13
View File
@@ -29,8 +29,20 @@ class TravelParser extends AbstractParser
Constants::PSEUDO_PRICE_ROOM_CODE, Constants::PSEUDO_PRICE_ROOM_CODE,
]; ];
/**
* `$levelBoardingPointPrices` carries no default on purpose: a hand-built parser would
* otherwise silently ignore APP_BPN_LEVEL_BOARDING_POINT_PRICES and price one travel tier
* differently from the others. The registry gave up its own default along with it, so that
* there is one way to build this class — through the container, or explicitly in a test.
*
* @param bool $levelBoardingPointPrices Whether to compensate the BusPro export defect that
* omits a boarding point's return-leg price. The
* container wires it from
* APP_BPN_LEVEL_BOARDING_POINT_PRICES.
*/
public function __construct( public function __construct(
private readonly AgeConstraintParserRegistry $ageConstraintRegistry = new AgeConstraintParserRegistry(), private readonly AgeConstraintParserRegistry $ageConstraintRegistry,
private readonly bool $levelBoardingPointPrices,
) { ) {
} }
@@ -87,8 +99,10 @@ class TravelParser extends AbstractParser
$travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true); $travel->pickups = $this->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true);
$travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false); $travel->dropOffs = $this->getPickups($node->filterXPath('.//zustiege_rueck/zustieg_rueck'), $dateTo, false);
// Merge drop-off prices into pickups for split pricing support // A boarding point costs the same in both directions
$this->mergeDropOffPricesIntoPickups($travel->pickups, $travel->dropOffs); if ($this->levelBoardingPointPrices) {
$this->applyBoardingPointPriceLevelling($travel->pickups, $travel->dropOffs);
}
$travel->guide = $this->getGuide($node); $travel->guide = $this->getGuide($node);
@@ -453,22 +467,43 @@ class TravelParser extends AbstractParser
} }
/** /**
* Merges drop-off prices into pickups for split pricing support. * Levels the price of a boarding point across both travel directions.
* *
* For each pickup, finds the matching drop-off by ID and copies * A boarding point is either free or costs the same in both directions:
* the inbound price to the pickup's priceInbound property. This enables * `ZustiegPreis` is keyed by (point, tariff scheme) and holds no per-leg price, so
* split pricing calculations where costs are distributed between outbound * BusPro bills an `Abfahrtsortzuschlag` per leg at one and the same rate. An
* and inbound transportation. * asymmetric pair is therefore always an export defect — on some products a
* `<zustieg_rueck>` carries no `preis` although the engine still charges the return
* leg — and the stated price is the one that applies to both legs.
*
* Where both directions state a price they agree in practice; the outbound one wins
* to keep the outcome deterministic. Points without a counterpart are left as they are.
*
* Compensating the defect locally is provisional, so it is switchable: with
* `$levelBoardingPointPrices` off the export is reported verbatim and the affected
* travels undercharge the return leg again. See
* `docs/buspro-database-reference.md` section A7.
* *
* @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place) * @param array<int, Pickup> $pickups Pickups indexed by ID (modified in place)
* @param array<int, Pickup> $dropOffs Drop-offs indexed by ID * @param array<int, Pickup> $dropOffs Drop-offs indexed by ID (modified in place)
*/ */
private function mergeDropOffPricesIntoPickups(array &$pickups, array $dropOffs): void private function applyBoardingPointPriceLevelling(array $pickups, array $dropOffs): void
{ {
foreach ($pickups as $pickupId => $pickup) { foreach ($dropOffs as $pickupId => $dropOff) {
if (isset($dropOffs[$pickupId])) { if (false === isset($pickups[$pickupId])) {
$pickup->priceInbound = $dropOffs[$pickupId]->priceInbound; continue;
} }
$pickup = $pickups[$pickupId];
$price = $pickup->price ?? $dropOff->price;
$pickup->price = $price;
$pickup->priceOutbound = $price;
$pickup->priceInbound = $price;
$dropOff->price = $price;
$dropOff->priceOutbound = $price;
$dropOff->priceInbound = $price;
} }
} }
@@ -6,11 +6,13 @@ namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\Accommodation; use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType; use App\Form\Admin\Groups\AccommodationBookingChangeAccommodationType;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -49,7 +51,24 @@ class ChangeAccommodationController extends AbstractController
$selected = $form->get('accommodation')->getData(); $selected = $form->get('accommodation')->getData();
if ($selected instanceof Accommodation && $selected !== $booking->getAccommodation()) { if ($selected instanceof Accommodation && $selected !== $booking->getAccommodation()) {
$this->bookingService->changeAccommodation($booking, $selected); try {
$this->bookingService->changeAccommodation($booking, $selected);
} catch (ImplausibleBookingPriceException $e) {
// The house that was picked prices this stay implausibly high, so the change
// did not happen. Reported in the modal the office is standing in front of —
// the same treatment the create and edit forms give it.
$this->logger->error('Refused to change accommodation over an implausible booking total', [
'exception' => $e,
'id' => $booking->getId(),
'accommodation' => $selected->getCalendarCode(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
return $this->render('admin/accommodation_booking/modal_change_accommodation.html.twig', [
'booking' => $booking,
'form' => $form,
]);
}
$this->addFlash('success', 'Das Gruppenhaus wurde geändert. Bitte wähle die Leistungen neu aus.'); $this->addFlash('success', 'Das Gruppenhaus wurde geändert. Bitte wähle die Leistungen neu aus.');
@@ -6,12 +6,15 @@ namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Entity\User; use App\Entity\User;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingCreateType; use App\Form\Admin\Groups\AccommodationBookingCreateType;
use App\Htmx\HxRedirectResponse; use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -45,8 +48,7 @@ class CreateController extends AbstractController
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid() && $this->refreshPriceSnapshot($form, $booking)) {
$this->bookingService->refreshPriceSnapshot($booking);
$this->entityManager->persist($booking); $this->entityManager->persist($booking);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -65,4 +67,31 @@ class CreateController extends AbstractController
'form' => $form, 'form' => $form,
]); ]);
} }
/**
* The price is computed after validation, so an implausible total cannot be a constraint on
* the form — it is reported as one anyway, because the office's next move is to go and fix
* the house's prices, not to read an error page.
*
* @param FormInterface<AccommodationBooking> $form
*/
private function refreshPriceSnapshot(FormInterface $form, AccommodationBooking $booking): bool
{
try {
$this->bookingService->refreshPriceSnapshot($booking);
return true;
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to store an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'accommodation' => $booking->getAccommodation()?->getCalendarCode(),
'paxCount' => $booking->getPaxCount(),
'nights' => $booking->getNights(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
return false;
}
}
} }
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Copying is its own named action rather than a flag on the edit route the way it is for the
* catalog entities (see Admin\AdditionalService\EditController): a booking carries a lifecycle,
* so the copy has to be reset and written before anybody edits it, and the edit form is built
* from a persisted record's house and service catalog.
*
* Unlike absagen and löschen this has no status guard — the original is not touched, so a
* confirmed or discarded booking may be copied just as well as a draft.
*/
#[IsGranted('ROLE_GROUPS_MANAGER')]
class DuplicateController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/duplicate', name: 'app_admin_accommodationbooking_duplicate')]
public function index(AccommodationBooking $booking, Request $request): Response
{
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('duplicate_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$copy = $this->bookingService->duplicate($booking);
$this->addFlash('success', 'Die Kopie wurde als neuer Entwurf angelegt.');
$this->logger->info('Duplicated accommodation booking', [
'id' => $booking->getId(),
'copyId' => $copy->getId(),
'groupName' => $copy->getGroupName(),
]);
// Straight into the copy's edit form — the copy exists to be changed, and that is
// the only reason it was made. The return url is forwarded still encoded, the way
// return_url() handed it over, so "zurück" still leads to the list this started from.
$parameters = ['id' => $copy->getId()];
$returnUrl = $request->query->getString('r');
if ('' !== $returnUrl) {
$parameters['r'] = $returnUrl;
}
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', $parameters));
}
return $this->render('admin/accommodation_booking/modal_duplicate.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'duplicate_accommodation_booking_'.$booking->getId(),
]);
}
}
@@ -7,6 +7,7 @@ namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService; use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService; use App\Entity\Groups\BoardService;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingType; use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository; use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository; use App\Repository\Groups\BoardServiceRepository;
@@ -15,6 +16,8 @@ use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -121,7 +124,15 @@ class EditController extends AbstractController
} }
} }
$this->bookingService->refreshPriceSnapshot($booking); if (!$this->refreshPriceSnapshot($form, $booking)) {
// The submitted changes stay on the entity but are never flushed, so the form
// comes back filled in with the error on it. Logging is safe here: the database
// handler writes through DBAL, not the EntityManager.
return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking,
'form' => $form,
]);
}
// Editing never moves the booking on and never mails the customer: every status // Editing never moves the booking on and never mails the customer: every status
// change is its own named action (Angebot senden, bestätigen, absagen), so the // change is its own named action (Angebot senden, bestätigen, absagen), so the
@@ -154,4 +165,31 @@ class EditController extends AbstractController
'form' => $form, 'form' => $form,
]); ]);
} }
/**
* The price is computed after validation, so an implausible total cannot be a constraint on
* the form — it is reported as one anyway, because the office's next move is to go and fix
* the house's prices, not to read an error page.
*
* @param FormInterface<AccommodationBooking> $form
*/
private function refreshPriceSnapshot(FormInterface $form, AccommodationBooking $booking): bool
{
try {
$this->bookingService->refreshPriceSnapshot($booking);
return true;
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to store an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'accommodation' => $booking->getAccommodation()?->getCalendarCode(),
'paxCount' => $booking->getPaxCount(),
'nights' => $booking->getNights(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
return false;
}
}
} }
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Model\TravelExportQuery;
use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Bulk exports, for consumers that mirror our data rather than query it per row.
*
* Deliberately its own resource rather than a verb hung off the collection it exports.
* `/api/travels/{dateCode}` accepts an arbitrary string, so an `/api/travels/export` would
* share a namespace with every date code and could only be kept apart from one by a
* negative lookahead on the sibling route -- a constraint nothing in the URL announces and
* which silently mis-routes when it is wrong. Under `/api/exports` the collision does not
* exist: this prefix never takes a parameterised segment, exactly like `/api/contingents`.
*/
#[Route('/api/exports')]
#[IsGranted('ROLE_OAUTH2_API')]
class ExportController extends AbstractController
{
public function __construct(
private readonly TravelExportBuilder $travelExportBuilder,
) {
}
/**
* Every travel date x hotel pair with its pickups resolved, paginated.
*
* The counters in meta describe the whole export, not the page, so they repeat
* unchanged on every page and a consumer can abort after the first one.
*/
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
public function travels(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
TravelExportQuery $query = new TravelExportQuery(),
): JsonResponse {
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
// status codes, so a failure here has to surface as a 5xx.
$export = $this->travelExportBuilder->build();
$total = count($export->rows);
return $this->json([
'data' => array_slice($export->rows, ($query->page - 1) * $query->perPage, $query->perPage),
'meta' => [
'page' => $query->page,
'perPage' => $query->perPage,
'total' => $total,
'totalPages' => (int) ceil($total / $query->perPage),
'skipped' => $export->skipped,
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
'generatedAt' => $export->generatedAt->format('c'),
],
]);
}
}
+5 -1
View File
@@ -58,7 +58,11 @@ class UserinfoController extends AbstractController
// addresses on a person's record as a login and answers all of them with the same // 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 // 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. // account and the private one apart — and they hold different roles.
$data->subject = (string) $user->getId(); //
// The uuid, not the primary key: consumers store this value and match their own
// accounts on it, so it must not disclose how many accounts exist or let one
// account's identifier be guessed from another's.
$data->subject = $user->getUuid();
$data->loginEmail = $user->getEmail(); $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
@@ -13,6 +13,7 @@ use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto; use App\Form\Model\RoomSelectionDto;
use App\Model\RoomSelectionAdjustment;
use App\Service\BookingConfigurator; use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory; use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager; use App\Service\BookingSessionManager;
@@ -66,6 +67,12 @@ class Step2Controller extends AbstractBookingCreateController
// Enrich with fresh availability data // Enrich with fresh availability data
$this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel); $this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel);
// Resync the room selections with the refreshed travel before any participant count is derived
// from them, and send the customer back to step 1 when a room they picked is gone.
if (null !== $redirect = $this->guardAgainstUnavailableRoomSelection($request, $bookingCreateDto)) {
return $redirect;
}
// Ensure correct number of participants first, then prepopulate the applicant if needed. // Ensure correct number of participants first, then prepopulate the applicant if needed.
if (null !== $redirect = $this->guardAgainstEmptyParticipantList($bookingCreateDto)) { if (null !== $redirect = $this->guardAgainstEmptyParticipantList($bookingCreateDto)) {
return $redirect; return $redirect;
@@ -120,6 +127,79 @@ class Step2Controller extends AbstractBookingCreateController
return $this->render('booking/create/step_2.html.twig', $templateData); return $this->render('booking/create/step_2.html.twig', $templateData);
} }
/**
* Sends the customer back to step 1 when the refreshed travel data no longer covers their rooms.
*
* Deriving the participant count from a room that has disappeared would silently fall back to a
* capacity of one and render too few participants, and a quantity the contingent no longer covers
* reaches the BusPro submit unchecked, because no later step re-reads room availability. The
* selections are resynced and saved before every exit, so step 1 and the neighbouring guard both
* see a collection that matches the rooms the template renders: leaving a stale entry behind would
* fail step 1's own validation on a field it never shows.
*/
private function guardAgainstUnavailableRoomSelection(Request $request, BookingDto $bookingCreateDto): ?RedirectResponse
{
$adjustments = $this->bookingService->reconcileRoomSelections($bookingCreateDto);
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
if ([] === $bookingCreateDto->travel->getAvailableRooms()) {
$this->handleApiError(
$this->logger,
'Travel has no bookable rooms left while a booking is in progress.',
$this->createRoomContext($bookingCreateDto, $adjustments),
'Für diese Reise sind leider keine Zimmer mehr verfügbar.'
);
return $this->redirectToRoute('app_booking_create_error');
}
if ([] === $adjustments) {
return null;
}
$hasDroppedRoom = false;
foreach ($adjustments as $adjustment) {
$hasDroppedRoom = $hasDroppedRoom || $adjustment->isDropped();
}
$this->handleApiError(
$this->logger,
'Room selection exceeds what the current travel data still offers.',
$this->createRoomContext($bookingCreateDto, $adjustments),
true === $hasDroppedRoom
? 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.'
: 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.'
);
return $this->redirectToRoute('app_booking_create_step_1');
}
/**
* @param list<RoomSelectionAdjustment> $adjustments
*
* @return array<string, mixed>
*/
private function createRoomContext(BookingDto $bookingCreateDto, array $adjustments): array
{
$rooms = [];
foreach ($adjustments as $adjustment) {
$rooms[] = [
'roomId' => $adjustment->roomId,
'label' => $adjustment->label,
'requestedQuantity' => $adjustment->requestedQuantity,
'bookableQuantity' => $adjustment->bookableQuantity,
];
}
return [
'travelId' => $bookingCreateDto->travel->id,
'travelCode' => $bookingCreateDto->travel->code,
'hotelId' => $bookingCreateDto->hotelId,
'rooms' => $rooms,
];
}
/** /**
* Rejects a room selection that resolves to no participants at all. * Rejects a room selection that resolves to no participants at all.
* *
@@ -190,7 +270,14 @@ class Step2Controller extends AbstractBookingCreateController
$rooms = $travelData->getAvailableRooms(); $rooms = $travelData->getAvailableRooms();
foreach ($roomSelections as $roomSelection) { foreach ($roomSelections as $roomSelection) {
$room = $rooms[$roomSelection->id]; $room = $rooms[$roomSelection->id] ?? null;
if (null === $room) {
// Unreachable from index(), which reconciles the selections first. Kept so a future
// caller cannot silently fall back to a capacity of one and book too few people.
continue;
}
$participantsCount += ($room->minPax ?? 1) * $roomSelection->quantity; $participantsCount += ($room->minPax ?? 1) * $roomSelection->quantity;
} }
@@ -10,16 +10,14 @@ use App\BusProNet\Model\Notification;
use App\Entity\User; use App\Entity\User;
use App\Exception\BookingSessionNotFoundException; use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException; use App\Exception\HotelNotInTravelException;
use App\Exception\NewsletterProviderException;
use App\Exception\TravelNotFoundException; use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep4Type; use App\Form\BookingCreateStep4Type;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Service\BookingConfigurator; use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory; use App\Service\BookingCreateContextFactory;
use App\Service\BookingNewsletterOptIn;
use App\Service\BookingSessionManager; use App\Service\BookingSessionManager;
use App\Service\NewsletterManager;
use App\Service\ParticipantFormSupport; use App\Service\ParticipantFormSupport;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
@@ -41,7 +39,7 @@ class Step4Controller extends AbstractBookingCreateController
private readonly BookingCreateContextFactory $createContextFactory, private readonly BookingCreateContextFactory $createContextFactory,
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly NewsletterManager $doubleOptInService, private readonly BookingNewsletterOptIn $bookingNewsletterOptIn,
private readonly ParticipantFormSupport $participantFormSupportService, private readonly ParticipantFormSupport $participantFormSupportService,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
@@ -64,11 +62,15 @@ class Step4Controller extends AbstractBookingCreateController
return $redirect; return $redirect;
} }
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto); $user = $this->getUser();
$newsletterTargetEmail = $this->bookingNewsletterOptIn->resolveApplicantEmail($bookingCreateDto, $user instanceof User ? $user : null);
$newsletterOptInVisible = null !== $newsletterTargetEmail; $newsletterOptInVisible = null !== $newsletterTargetEmail;
$companions = $this->bookingNewsletterOptIn->resolveCompanions($bookingCreateDto, $newsletterTargetEmail);
$companionNewsletterOptInVisible = [] !== $companions;
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [ $form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
'show_newsletter_opt_in' => $newsletterOptInVisible, 'show_newsletter_opt_in' => $newsletterOptInVisible,
'show_companion_newsletter_opt_in' => $companionNewsletterOptInVisible,
'newsletter_target_email' => $newsletterTargetEmail, 'newsletter_target_email' => $newsletterTargetEmail,
]); ]);
$form->handleRequest($request); $form->handleRequest($request);
@@ -94,7 +96,7 @@ class Step4Controller extends AbstractBookingCreateController
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.', $bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.',
); );
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
} }
if (false === $bookingResponse->isBookingSuccessful()) { if (false === $bookingResponse->isBookingSuccessful()) {
@@ -110,7 +112,7 @@ class Step4Controller extends AbstractBookingCreateController
$errorMessage, $errorMessage,
); );
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
} }
$newsletterOptInSelected = $newsletterOptInVisible $newsletterOptInSelected = $newsletterOptInVisible
@@ -118,19 +120,21 @@ class Step4Controller extends AbstractBookingCreateController
&& true === $form->get('newsletterOptIn')->getData(); && true === $form->get('newsletterOptIn')->getData();
if (true === $newsletterOptInSelected) { if (true === $newsletterOptInSelected) {
try { $this->bookingNewsletterOptIn->subscribeApplicant(
$targetParticipant = $this->resolveNewsletterTargetParticipant($bookingCreateDto); $newsletterTargetEmail,
$this->doubleOptInService->requestConfirmation( $this->bookingNewsletterOptIn->resolveApplicantParticipant($bookingCreateDto),
$newsletterTargetEmail, );
$targetParticipant?->firstName, }
$targetParticipant?->lastName,
); $companionOptInSelected = $companionNewsletterOptInVisible
} catch (NewsletterProviderException|\InvalidArgumentException $e) { && $form->has('companionNewsletterOptIn')
$this->logger->warning('Newsletter confirmation request failed after booking', [ && true === $form->get('companionNewsletterOptIn')->getData();
'email' => $newsletterTargetEmail,
'error' => $e->getMessage(), if (true === $companionOptInSelected) {
]); $this->bookingNewsletterOptIn->subscribeCompanions(
} $companions,
$this->bookingNewsletterOptIn->resolveRequestedByName($bookingCreateDto),
);
} }
// Success: Store booking data in flash for conversion tracking // Success: Store booking data in flash for conversion tracking
@@ -171,7 +175,7 @@ class Step4Controller extends AbstractBookingCreateController
} }
} }
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible);
} }
/** /**
@@ -185,6 +189,7 @@ class Step4Controller extends AbstractBookingCreateController
FormInterface $form, FormInterface $form,
bool $newsletterOptInVisible, bool $newsletterOptInVisible,
?string $newsletterTargetEmail, ?string $newsletterTargetEmail,
bool $companionNewsletterOptInVisible,
): Response { ): Response {
$bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto); $bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto);
@@ -193,45 +198,10 @@ class Step4Controller extends AbstractBookingCreateController
'form' => $form, 'form' => $form,
'newsletterOptInVisible' => $newsletterOptInVisible, 'newsletterOptInVisible' => $newsletterOptInVisible,
'newsletterTargetEmail' => $newsletterTargetEmail, 'newsletterTargetEmail' => $newsletterTargetEmail,
'companionNewsletterOptInVisible' => $companionNewsletterOptInVisible,
]); ]);
} }
private function resolveNewsletterTargetEmail(BookingDto $bookingDto): ?string
{
$currentUser = $this->getUser();
$email = null;
if ($currentUser instanceof User) {
$email = $currentUser->getEmail();
}
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
$email = $bookingDto->participants[0]->email;
}
if (null === $email) {
return null;
}
$normalizedEmail = mb_strtolower(trim($email));
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
}
private function resolveNewsletterTargetParticipant(BookingDto $bookingDto): ?ParticipantDto
{
$participant = $bookingDto->participants[0] ?? null;
if (null === $participant) {
return null;
}
if (null === $participant->firstName && null === $participant->lastName) {
return null;
}
return $participant;
}
/** /**
* Clears travel data and availability cache after successful booking. * Clears travel data and availability cache after successful booking.
*/ */
@@ -8,6 +8,7 @@ use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService; use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService; use App\Entity\Groups\BoardService;
use App\Exception\AccommodationSessionNotFoundException; use App\Exception\AccommodationSessionNotFoundException;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\AccommodationBookingConfirmationType; use App\Form\AccommodationBookingConfirmationType;
use App\Form\AccommodationInquiryConfirmationType; use App\Form\AccommodationInquiryConfirmationType;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
@@ -16,6 +17,7 @@ use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager; use App\Service\AccommodationSessionManager;
use App\Service\AccommodationTermsUrlProvider; use App\Service\AccommodationTermsUrlProvider;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -28,6 +30,7 @@ class Step4Controller extends AbstractController
private readonly AccommodationBookingService $bookingService, private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager, private readonly AccommodationSessionManager $sessionManager,
private readonly AccommodationTermsUrlProvider $termsUrlProvider, private readonly AccommodationTermsUrlProvider $termsUrlProvider,
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -51,7 +54,9 @@ class Step4Controller extends AbstractController
} }
$dto->forceInquiry = true; $dto->forceInquiry = true;
$this->persistBooking($request, $dto, $accommodation, $services); if (null !== $failure = $this->persistBooking($request, $dto, $accommodation, $services)) {
return $failure;
}
return $this->redirectToRoute('app_groups_booking_success'); return $this->redirectToRoute('app_groups_booking_success');
} }
@@ -102,7 +107,9 @@ class Step4Controller extends AbstractController
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
$services = $this->bookingService->loadAvailableServices($dto, $accommodation); $services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$this->persistBooking($request, $dto, $accommodation, $services); if (null !== $failure = $this->persistBooking($request, $dto, $accommodation, $services)) {
return $failure;
}
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success')); return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success'));
} }
@@ -148,12 +155,35 @@ class Step4Controller extends AbstractController
* groupedAdditionalServices: array<string, AdditionalService[]>, * groupedAdditionalServices: array<string, AdditionalService[]>,
* ungroupedAdditionalServices: AdditionalService[] * ungroupedAdditionalServices: AdditionalService[]
* } $services * } $services
*
* @return Response|null a response to return instead of the success redirect, or null when
* the booking was stored
*/ */
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): ?Response
{ {
$prices = $this->bookingService->loadPrices($dto, $accommodation); $prices = $this->bookingService->loadPrices($dto, $accommodation);
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
try {
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
} catch (ImplausibleBookingPriceException $e) {
// The bounded inputs are already rejected in step 2, so what is left is a mistyped
// catalog price for this house — nothing the customer can act on and nothing to show
// them an error page over. The session survives so the desk can pick the inquiry up
// by phone with the data still in hand.
$this->logger->error('Refused to store an implausible accommodation booking total', [
'exception' => $e,
'accommodation' => $accommodation->getCalendarCode(),
'paxCount' => $dto->paxCount,
'nights' => $dto->getNights(),
]);
$this->addFlash('error', 'Die Anfrage konnte nicht abgeschlossen werden. Bitte wende dich direkt an uns — wir kümmern uns darum.');
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_error'));
}
$this->addFlash('groups_booking_result', $booking->getOrigin()->value); $this->addFlash('groups_booking_result', $booking->getOrigin()->value);
$this->sessionManager->clear($request); $this->sessionManager->clear($request);
return null;
} }
} }
+53 -12
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Service\BookingSessionManager; use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler; use App\Service\BookingSummaryAssembler;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -21,13 +22,28 @@ class SecurityController extends AbstractController
Request $request, Request $request,
BookingSessionManager $bookingSessionService, BookingSessionManager $bookingSessionService,
BookingSummaryAssembler $summaryDataService, BookingSummaryAssembler $summaryDataService,
ClientManagerInterface $clientManager,
): Response { ): Response {
// flag this request in the session as external OAuth2 request if
// applicable to immediately logout the current user after successful
// authentication (see App\EventListener\AuthorizationCodeListener).
// Read before the booking flow is decided, because it overrules it.
$session = $request->getSession();
$targetPath = $session->get('_security.main.target_path');
$isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize');
$session->set('_oauth2', $isOauth2);
// Check if this is a booking flow (BookingDto exists in session) // Check if this is a booking flow (BookingDto exists in session)
$bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE); $bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE);
$isBookingFlow = null !== $bookingDto; $hasPendingBooking = null !== $bookingDto;
// A pending authorization wins over a half-finished booking. Otherwise the target path
// below is overwritten with booking step 1, the user is sent there after logging in, and
// the application that asked for the authorization never receives its code.
$isBookingFlow = $hasPendingBooking && false === $isOauth2;
// If authenticated and in booking flow, proceed to Step 1 // If authenticated and in booking flow, proceed to Step 1
if (null !== $this->getUser() && true === $isBookingFlow) { if (null !== $this->getUser() && true === $hasPendingBooking) {
return $this->redirectToRoute('app_booking_create_step_1'); return $this->redirectToRoute('app_booking_create_step_1');
} }
@@ -39,21 +55,18 @@ class SecurityController extends AbstractController
$error = $authenticationUtils->getLastAuthenticationError(); $error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername(); $lastUsername = $authenticationUtils->getLastUsername();
// flag this request in the session as external OAuth2 request if
// applicable to immediately logout the current user after successful
// authentication (see App\EventListener\AuthorizationCodeListener).
$session = $request->getSession();
$targetPath = $session->get('_security.main.target_path');
$isOauth2 = null !== $targetPath && str_contains($targetPath, '/authorize');
$session->set('_oauth2', $isOauth2);
// For booking flow, set target path to Step 1 (after successful auth, redirect there) // For booking flow, set target path to Step 1 (after successful auth, redirect there)
if (true === $isBookingFlow) { if (true === $isBookingFlow) {
$session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1')); $session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1'));
} }
// Render booking login template if in booking flow, otherwise standard login // Render booking login template if in booking flow, the self-explaining one when another
$template = true === $isBookingFlow ? 'booking/create/authenticate.html.twig' : 'security/login.html.twig'; // application sent them here to authorize it, otherwise standard login
$template = match (true) {
$isBookingFlow => 'booking/create/authenticate.html.twig',
$isOauth2 => 'security/login_oauth2.html.twig',
default => 'security/login.html.twig',
};
// Fetch CMS data for booking flow (uses cache warmed in IndexController) // Fetch CMS data for booking flow (uses cache warmed in IndexController)
$cmsData = null; $cmsData = null;
@@ -72,9 +85,37 @@ class SecurityController extends AbstractController
'travel_date_to' => $bookingDto?->travel->dateTo, 'travel_date_to' => $bookingDto?->travel->dateTo,
'cmsData' => $cmsData, 'cmsData' => $cmsData,
'oauth2' => $isOauth2, 'oauth2' => $isOauth2,
'client_name' => $isOauth2 ? $this->authorizingClientName($clientManager, $targetPath) : null,
]); ]);
} }
/**
* The display name of the application that sent the user here to authorize it, so the login page
* can name it instead of leaving the user wondering why the site changed.
*
* Derived from the pending target path on every render rather than stashed on the session: a
* failed login redirects back to this route and the target path survives, so the name comes back
* with it. Returns null when the client cannot be resolved, and the page falls back to neutral
* wording rather than failing -- this runs before authentication.
*/
private function authorizingClientName(ClientManagerInterface $clientManager, ?string $targetPath): ?string
{
$query = null !== $targetPath ? parse_url($targetPath, \PHP_URL_QUERY) : null;
if (!\is_string($query) || '' === $query) {
return null;
}
parse_str($query, $params);
$clientId = $params['client_id'] ?? null;
if (!\is_string($clientId) || '' === $clientId) {
return null;
}
$name = $clientManager->find($clientId)?->getName() ?? '';
return '' !== $name ? $name : null;
}
#[Route('/logout', name: 'app_logout')] #[Route('/logout', name: 'app_logout')]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function logout(): void public function logout(): void
+79 -1
View File
@@ -12,11 +12,13 @@ use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType; use App\Enum\Groups\AdditionalServiceType;
use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AccommodationBookingRepository; use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)] #[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)]
class AccommodationBooking implements BlameableEntityInterface, TimestampableEntityInterface class AccommodationBooking implements BlameableEntityInterface, TimestampableEntityInterface
@@ -24,6 +26,24 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
use BlameableEntity; use BlameableEntity;
use TimestampableEntity; use TimestampableEntity;
/**
* Plausibility ceilings for the two figures every price in the breakdown is multiplied by.
* Both sit above anything the group houses actually see — the largest booking on record is
* 150 people for 18 nights — and exist so that a mistyped year or pax count is answered with
* a form error rather than a price nobody meant.
*/
public const int MAX_PAX = 250;
public const int MAX_NIGHTS = 30;
/**
* And the ceiling on the product of the two, in minor units: a million euros against a
* largest-ever booking of some 42,000. The inputs above cannot reach it, so what it catches
* is a mistyped catalog price, the one factor of the total that is not bounded per booking.
* Deliberately well inside what the INT column holds — the column's range is not a price
* limit, and a total that needs more room is a mistake rather than a wider column.
*/
public const int MAX_TOTAL_PRICE = 100_000_000;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -40,9 +60,11 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
private ?\DateTimeImmutable $dateFrom = null; private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)] #[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Abreisedatum muss nach dem Anreisedatum liegen.')]
private ?\DateTimeImmutable $dateTo = null; private ?\DateTimeImmutable $dateTo = null;
#[ORM\Column] #[ORM\Column]
#[Assert\Range(min: 0, max: self::MAX_PAX)]
private int $paxCount = 0; private int $paxCount = 0;
#[ORM\Column] #[ORM\Column]
@@ -68,6 +90,7 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\Column(type: Types::JSON, nullable: true)] #[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $priceBreakdown = null; private ?array $priceBreakdown = null;
/** In minor units, like every other price here, and never above self::MAX_TOTAL_PRICE. */
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $totalPrice = null; private ?int $totalPrice = null;
@@ -177,6 +200,34 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
$this->uuid = Uuid::v4()->toRfc4122(); $this->uuid = Uuid::v4()->toRfc4122();
} }
/**
* A copy is a new record, not a second view of the original: it gets its own identity and
* its own access link token — sharing that token would put two records behind one customer
* link — and every mark the lifecycle left is wiped, so the copy starts as an Entwurf that
* has never been in front of a customer. The origin is rewritten too: however the original
* came about, the copy comes about as an offer the office prepared, which is what keeps the
* field truthful.
*
* The price snapshot, the service snapshots and all four discounts are deliberately carried
* over untouched. A copy is made to vary an offer that was already quoted, so it has to show
* the same numbers as the record it came from rather than a re-quote at today's catalog
* prices; saving it through the edit form recomputes it.
*/
public function __clone()
{
$this->id = null;
$this->uuid = Uuid::v4()->toRfc4122();
$this->status = AccommodationBookingStatus::Draft;
$this->origin = AccommodationBookingOrigin::Offer;
$this->accessLinkIssuedAt = null;
$this->acceptedAt = null;
$this->confirmedAt = null;
$this->createdAt = null;
$this->updatedAt = null;
$this->createdBy = null;
$this->updatedBy = null;
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -232,6 +283,25 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->dateFrom->diff($this->dateTo)->days; return $this->dateFrom->diff($this->dateTo)->days;
} }
/**
* Reported on dateTo, the field a mistyped year is corrected in — a stay of 200 years is
* always the departure date being wrong, never the arrival.
*/
#[Assert\Callback]
public function validateStayLength(ExecutionContextInterface $context): void
{
if (null === $this->dateFrom || null === $this->dateTo) {
return;
}
if ($this->getNights() > self::MAX_NIGHTS) {
$context->buildViolation('Der Aufenthalt darf höchstens {{ max }} Nächte umfassen — bitte Anreise und Abreise prüfen.')
->setParameter('{{ max }}', (string) self::MAX_NIGHTS)
->atPath('dateTo')
->addViolation();
}
}
public function getPaxCount(): int public function getPaxCount(): int
{ {
return $this->paxCount; return $this->paxCount;
@@ -336,9 +406,17 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this->priceBreakdown; return $this->priceBreakdown;
} }
/** @param array<string, mixed> $priceBreakdown */ /**
* @param array<string, mixed> $priceBreakdown
*
* @throws ImplausibleBookingPriceException if the total is too large to be a real price
*/
public function setPriceSnapshot(array $priceBreakdown, int $totalPrice, string $currency, int $version): self public function setPriceSnapshot(array $priceBreakdown, int $totalPrice, string $currency, int $version): self
{ {
if ($totalPrice > self::MAX_TOTAL_PRICE) {
throw new ImplausibleBookingPriceException($totalPrice, $this->paxCount, $this->getNights());
}
$this->priceBreakdown = $priceBreakdown; $this->priceBreakdown = $priceBreakdown;
$this->totalPrice = $totalPrice; $this->totalPrice = $totalPrice;
$this->pricingCurrency = $currency; $this->pricingCurrency = $currency;
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use League\Bundle\OAuth2ServerBundle\Model\AbstractClient;
/**
* An OAuth2 client, extended with the roles an account must hold to authorize it.
*
* The bundle ships its own Client model and maps it for us. Replacing it (via
* league_oauth2_server.client.classname) is the supported way to add a column: the bundle
* maps AbstractClient as a mapped superclass and carries name, secret, redirectUris,
* grants, scopes, active and allowPlainTextPkce, then stops mapping its own Client, so
* this class only has to declare the table, the primary key and what it adds.
*
* Why the requirement lives here rather than in configuration: the alternative was a map
* in services.yaml keyed on the client identifier, and identifiers differ per environment,
* so that map would have needed an env var per client and would have drifted from the
* client table it describes. A column cannot drift from its own row.
*
* Nothing validates the contents. A misspelled role is not an error, it is a client that
* authorizes nobody, so AuthorizationCodeListener logs the requirement whenever it
* refuses — that is what makes the mistake visible without a database query.
*/
#[ORM\Entity]
#[ORM\Table(name: 'oauth2_client')]
class OAuth2Client extends AbstractClient
{
/**
* Declared protected on AbstractClient and mapped by the bundle only for its own
* Client, so the custom class has to map it itself. The type restates the parent's
* contract, which redeclaring the property would otherwise widen.
*
* @var non-empty-string
*/
#[ORM\Id]
#[ORM\Column(type: 'string', length: 32)]
protected string $identifier;
/**
* The roles that entitle an account to authorize this client. An empty list denies
* everyone: a row nobody has configured and a client that needs no roles are the same
* stored value, and of the two readings only "deny" is safe.
*
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $requiredRoles = [];
/**
* @return list<string>
*/
public function getRequiredRoles(): array
{
return $this->requiredRoles;
}
/**
* @param list<string> $requiredRoles
*/
public function setRequiredRoles(array $requiredRoles): self
{
$this->requiredRoles = $requiredRoles;
return $this;
}
}
+23
View File
@@ -6,6 +6,7 @@ use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface class User implements UserInterface, PasswordAuthenticatedUserInterface
@@ -15,6 +16,22 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private ?int $id = null; private ?int $id = null;
/**
* The account's external identifier, exported as the OAuth2 `sub` claim.
*
* Separate from the primary key on purpose: `sub` is handed to other applications, which
* store it and match their own accounts on it, so it has to be stable, unique and free of
* anything the row's position in the table implies. The auto-increment id satisfied the
* first two and neither of the last: it disclosed how many accounts exist and made one
* account's identifier a guess away from the next.
*
* v4 rather than the framework's default v7 (see config/packages/uid.yaml): v7 embeds its
* creation timestamp and sorts, which is useful for a key and is exactly the leak this
* column exists to avoid. Matches Groups\AccommodationBooking::$uuid in shape and version.
*/
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 180, unique: true)] #[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email; private ?string $email;
@@ -54,6 +71,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct(string $email) public function __construct(string $email)
{ {
$this->email = $email; $this->email = $email;
$this->uuid = Uuid::v4()->toRfc4122();
} }
public function getId(): ?int public function getId(): ?int
@@ -61,6 +79,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->id; return $this->id;
} }
public function getUuid(): string
{
return $this->uuid;
}
public function getEmail(): ?string public function getEmail(): ?string
{ {
return $this->email; return $this->email;
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Enum;
/**
* Who asked for a double opt-in mail — the recipient themselves, or the applicant of a
* booking they are travelling on. Only the wording of the confirmation mail depends on
* this: a third-party sign-up has to name who requested it and make clear that nothing
* is subscribed without the recipient's own click. Confirmation itself stays
* context-agnostic, so the value never has to be persisted.
*/
enum NewsletterOptInContext: string
{
case Own = 'own';
case Companion = 'companion';
public function subject(): string
{
return match ($this) {
self::Own => 'Deine Anmeldung zum E&P Newsletter',
self::Companion => 'Möchtest du den E&P Newsletter erhalten?',
};
}
public function template(): string
{
return match ($this) {
self::Own => 'email/newsletter_opt_in.html.twig',
self::Companion => 'email/newsletter_opt_in_companion.html.twig',
};
}
}
+89 -11
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\EventListener; namespace App\EventListener;
use App\Entity\OAuth2Client;
use App\Entity\User; use App\Entity\User;
use App\Security\Role;
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent; use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
use League\Bundle\OAuth2ServerBundle\OAuth2Events; use League\Bundle\OAuth2ServerBundle\OAuth2Events;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -14,14 +16,21 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
/** /**
* Handles OAuth2 authorization code grant flow. * Handles OAuth2 authorization code grant flow.
* *
* Automatically approves authorization requests for authenticated users without * Redirects anonymous users to login, then decides whether the account may authorize the
* consent prompts. Redirects anonymous users to login. For external OAuth2 * client that asked. Consent is deliberately not part of that decision: every client is
* requests (flagged via session), logs out the user after authorization to * one of our own applications, so there is nothing for the user to weigh up. What the user
* prevent session hijacking. * cannot be asked, though, is whether they are entitled to the application at all, and
* that is what this listener answers — a client declares the roles it requires in
* OAuth2Client::$requiredRoles and an account holding none of them is refused here rather
* than handed a token the client will reject for reasons it cannot explain.
*
* For external OAuth2 requests (flagged via session), logs out the user after a successful
* authorization to prevent session hijacking.
*/ */
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')] #[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')]
class AuthorizationCodeListener class AuthorizationCodeListener
@@ -31,6 +40,7 @@ class AuthorizationCodeListener
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
private readonly Security $security, private readonly Security $security,
private readonly LoggerInterface $authenticationLogger, private readonly LoggerInterface $authenticationLogger,
private readonly Environment $twig,
) { ) {
} }
@@ -53,13 +63,81 @@ class AuthorizationCodeListener
); );
$event->setResponse($response); $event->setResponse($response);
$this->authenticationLogger->info('Authorization request without session'); $this->authenticationLogger->info('Authorization request without session');
} else {
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED); return;
// in case this authorization request has been flagged as external in login controller, }
// immediately logout the current user (see App\Controller\Core\Security\LoginController).
if (true === $request->getSession()->get('_oauth2', false)) { $client = $event->getClient();
$this->security->logout(false);
} // the event is typed to the bundle's ClientInterface, which knows nothing about the
// column below: anything that is not our entity is a client we cannot have configured
if (false === $client instanceof OAuth2Client) {
$this->deny($event, $user, $client->getIdentifier(), [], 'unexpected client class');
return;
}
$required = $client->getRequiredRoles();
// An empty requirement is not "no requirement": it is the state a client row starts
// in, so it means nobody has said who may use this client yet.
if ([] === $required) {
$this->deny($event, $user, $client->getName(), $required, 'client has no roles configured');
return;
}
// effectiveOnly(), never getRoles(): a *_PENDING marker is a CRM nomination that
// grants nothing until an administrator approves it, and matching the raw role set
// would let a nomination open a staff application on its own.
if ([] === array_intersect($required, Role::effectiveOnly($user->getRoles()))) {
$this->deny($event, $user, $client->getName(), $required, 'insufficient roles');
return;
}
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED);
// in case this authorization request has been flagged as external in login controller,
// immediately logout the current user (see App\Controller\SecurityController).
if (true === $request->getSession()->get('_oauth2', false)) {
$this->security->logout(false);
} }
} }
/**
* Refuses the request and explains it on our own page.
*
* The resolution is left at its AUTHORIZATION_DENIED default; setting a response is what
* keeps the user here instead of bouncing them back to a client that would only be able
* to report that no authorization code arrived. The session is deliberately left intact
* — the user has to still be signed in to read the page and navigate away from it.
*
* Logged with the client's requirement but never with the user's roles: the requirement
* is configuration, and it is what makes a misspelled entry diagnosable from the log,
* while the account's roles are personal data and the auth channel is database-backed.
*
* @param list<string> $required
*/
private function deny(
AuthorizationRequestResolveEvent $event,
User $user,
string $client,
array $required,
string $reason,
): void {
$this->authenticationLogger->info('Authorization request denied', [
'client' => $client,
'user_id' => $user->getId(),
'required_roles' => $required,
'reason' => $reason,
]);
$event->setResponse(new Response(
// oauth2: true drops hx-boost in base.html.twig -- this page is rendered
// mid-redirect-chain, where boosting breaks the navigation
$this->twig->render('security/oauth2_denied.html.twig', ['client' => $client, 'oauth2' => true]),
Response::HTTP_FORBIDDEN
));
}
} }
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use App\Entity\Groups\AccommodationBooking;
/**
* Thrown when a computed accommodation booking total is too large to be a real price.
*
* The total is a product of pax count, nights and catalog prices, so a single mistyped figure
* anywhere in that chain can produce a number nobody could owe. Storing it is pointless — it
* would go out on an offer — and a signed INT column answers it with SQLSTATE 22003, which says
* nothing about where the mistake is. Failing here instead names the price and the inputs it
* came from.
*/
class ImplausibleBookingPriceException extends \RuntimeException
{
/**
* What the backoffice is told. The total itself is in the log; the actionable part is the
* house's price rows, which is where the stray digit is.
*/
public const string FORM_ERROR_MESSAGE = 'Der berechnete Gesamtpreis ist unplausibel hoch. Bitte die Preise und Leistungen des Gruppenhauses prüfen.';
public function __construct(int $totalPrice, int $paxCount, int $nights, ?\Throwable $previous = null)
{
parent::__construct(
sprintf(
'Refusing to store an implausible booking total of %d (ceiling %d, in minor units) for %d people over %d nights — check the catalog prices for this house.',
$totalPrice,
AccommodationBooking::MAX_TOTAL_PRICE,
$paxCount,
$nights,
),
0,
$previous,
);
}
}
+13
View File
@@ -44,6 +44,17 @@ class BookingCreateStep4Type extends AbstractType
]); ]);
} }
if (true === $options['show_companion_newsletter_opt_in']) {
// Deliberately no companion addresses in the label: the applicant already knows them,
// and rendering them would put other people's addresses on a shared screen.
$builder->add('companionNewsletterOptIn', CheckboxType::class, [
'label' => 'Ich möchte auch meine Mitfahrer:innen für den E&P Newsletter anmelden.',
'help' => 'Alle erhalten eine eigene Bestätigungs-E-Mail und werden erst nach ihrer eigenen Bestätigung angemeldet.',
'mapped' => false,
'required' => false,
]);
}
$builder $builder
->add('confirmationAccepted', CheckboxType::class, [ ->add('confirmationAccepted', CheckboxType::class, [
'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.', 'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.',
@@ -72,10 +83,12 @@ class BookingCreateStep4Type extends AbstractType
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => BookingDto::class, 'data_class' => BookingDto::class,
'show_newsletter_opt_in' => false, 'show_newsletter_opt_in' => false,
'show_companion_newsletter_opt_in' => false,
'newsletter_target_email' => null, 'newsletter_target_email' => null,
]); ]);
$resolver->setAllowedTypes('show_newsletter_opt_in', 'bool'); $resolver->setAllowedTypes('show_newsletter_opt_in', 'bool');
$resolver->setAllowedTypes('show_companion_newsletter_opt_in', 'bool');
$resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']); $resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']);
} }
} }
+7 -3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Model; namespace App\Form\Model;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
class AccommodationBookingDto class AccommodationBookingDto
@@ -16,13 +17,16 @@ class AccommodationBookingDto
public ?\DateTimeImmutable $dateTo = null; public ?\DateTimeImmutable $dateTo = null;
#[Assert\Positive(message: 'invalid', groups: ['step_3'])] // step_2 is where the customer types these, so that is the group that has to carry the
// bounds: the price is computed from them on the same screen, and an implausible figure
// reaching step 4 only surfaces as a failure to store the booking.
#[Assert\Range(min: 1, max: AccommodationBooking::MAX_PAX, notInRangeMessage: 'invalid', groups: ['step_2', 'step_3'])]
public int $paxCount = 1; public int $paxCount = 1;
#[Assert\PositiveOrZero(message: 'invalid', groups: ['step_3'])] #[Assert\Range(min: 0, max: AccommodationBooking::MAX_PAX, notInRangeMessage: 'invalid', groups: ['step_2', 'step_3'])]
public int $minorsCount = 0; public int $minorsCount = 0;
#[Assert\PositiveOrZero(message: 'invalid', groups: ['step_3'])] #[Assert\Range(min: 0, max: AccommodationBooking::MAX_PAX, notInRangeMessage: 'invalid', groups: ['step_2', 'step_3'])]
public int $childrenCount = 0; public int $childrenCount = 0;
#[Assert\NotNull(message: 'required', groups: ['step_2'])] #[Assert\NotNull(message: 'required', groups: ['step_2'])]
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* A room selection the current travel data no longer supports in full.
*
* A room that disappeared altogether is the same case with nothing left to book, so it is reported
* as an adjustment down to zero rather than as a second kind of result.
*/
final readonly class RoomSelectionAdjustment
{
public function __construct(
public int $roomId,
public ?string $label,
public int $requestedQuantity,
public int $bookableQuantity,
) {
}
/**
* Whether the room is gone entirely rather than merely reduced.
*/
public function isDropped(): bool
{
return 0 === $this->bookableQuantity;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* The whole travel-date x hotel export plus the counters that describe how it was built.
*
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
* makes the counters identical on every page: they describe the export, not the page, so a
* consumer can read them once and abort the page walk on the first response.
*
* @see \App\Service\TravelExportBuilder
*/
final readonly class TravelExport
{
/**
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
* @param int $skipped (travel, hotel) pairs dropped as unusable
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
*/
public function __construct(
public array $rows,
public int $skipped,
public int $pickupsWithoutMasterData,
public \DateTimeImmutable $generatedAt,
) {
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class TravelExportQuery
{
public function __construct(
#[Assert\Positive]
public int $page = 1,
#[Assert\Range(min: 1, max: 1000)]
public int $perPage = 500,
) {
}
}
+8 -1
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)
+49 -3
View File
@@ -13,6 +13,7 @@ use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService; 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\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingContext; use App\Model\AccommodationBookingContext;
use App\Model\AccommodationBookingQueryParams; use App\Model\AccommodationBookingQueryParams;
@@ -79,7 +80,8 @@ class AccommodationBookingService
/** /**
* Parses and validates raw date strings, then sets them on the DTO. * Parses and validates raw date strings, then sets them on the DTO.
* *
* @throws \InvalidArgumentException if dates are invalid or dateTo ≤ dateFrom * @throws \InvalidArgumentException if dates are invalid, dateTo ≤ dateFrom, or the stay
* exceeds AccommodationBooking::MAX_NIGHTS
*/ */
public function applyDates(AccommodationBookingDto $dto, string $dateFromRaw, string $dateToRaw): void public function applyDates(AccommodationBookingDto $dto, string $dateFromRaw, string $dateToRaw): void
{ {
@@ -94,6 +96,10 @@ class AccommodationBookingService
throw new \InvalidArgumentException('Das Abreisedatum muss nach dem Anreisedatum liegen.'); throw new \InvalidArgumentException('Das Abreisedatum muss nach dem Anreisedatum liegen.');
} }
if ($dateFrom->diff($dateTo)->days > AccommodationBooking::MAX_NIGHTS) {
throw new \InvalidArgumentException(sprintf('Der Aufenthalt darf höchstens %d Nächte umfassen.', AccommodationBooking::MAX_NIGHTS));
}
$dto->dateFrom = $dateFrom->setTime(0, 0, 0); $dto->dateFrom = $dateFrom->setTime(0, 0, 0);
$dto->dateTo = $dateTo->setTime(0, 0, 0); $dto->dateTo = $dateTo->setTime(0, 0, 0);
} }
@@ -566,7 +572,37 @@ class AccommodationBookingService
* their access link, and swapping the house underneath them would rewrite what they were * their access link, and swapping the house underneath them would rewrite what they were
* offered. * offered.
* Idempotent — a no-op for a booking that is not a draft or already sits at $accommodation. * Idempotent — a no-op for a booking that is not a draft or already sits at $accommodation.
*
* @throws ImplausibleBookingPriceException if the new house prices the stay implausibly high;
* the booking is left at its old house
*/ */
/**
* Copies a booking into a fresh Entwurf, so the office can build a variation of an offer
* instead of keying the whole thing in again. Everything the customer was quoted comes
* along verbatim — house, dates, pax, the board and additional service snapshots, the
* discounts and the stored price breakdown — while AccommodationBooking::__clone() resets
* the identity and the lifecycle.
*
* Allowed from any status, including Bestätigt and Absage: nothing on the original is
* touched, and "same group, next season" is exactly the case this exists for. The price is
* deliberately not recomputed, see __clone().
*/
public function duplicate(AccommodationBooking $booking): AccommodationBooking
{
$copy = clone $booking;
// Marks the copy where the office looks for it — the list is sorted and scanned by
// group name, and two identical rows are otherwise impossible to tell apart.
if (null !== $copy->getGroupName()) {
$copy->setGroupName($copy->getGroupName().' (Kopie)');
}
$this->entityManager->persist($copy);
$this->entityManager->flush();
return $copy;
}
public function changeAccommodation(AccommodationBooking $booking, Accommodation $accommodation): void public function changeAccommodation(AccommodationBooking $booking, Accommodation $accommodation): void
{ {
if (!$booking->isDraft() || $booking->getAccommodation() === $accommodation) { if (!$booking->isDraft() || $booking->getAccommodation() === $accommodation) {
@@ -580,8 +616,18 @@ class AccommodationBookingService
$booking->setBoardServiceOriginalId(null); $booking->setBoardServiceOriginalId(null);
$booking->setAdditionalServices([]); $booking->setAdditionalServices([]);
// Clears the snapshot by itself when the new house has no price for the booked range. try {
$this->refreshPriceSnapshot($booking); // Clears the snapshot by itself when the new house has no price for the booked range.
$this->refreshPriceSnapshot($booking);
} catch (ImplausibleBookingPriceException $e) {
// The swap and the dropped services are already on the managed entity, and the next
// flush in this request would write them without a price. Reloading the row puts the
// booking back at its old house so the caller can report the refusal on a record that
// still matches the database.
$this->entityManager->refresh($booking);
throw $e;
}
$this->entityManager->flush(); $this->entityManager->flush();
} }
+49
View File
@@ -15,6 +15,7 @@ use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto; use App\Form\Model\RoomSelectionDto;
use App\Form\Service\ServiceAgeEvaluator; use App\Form\Service\ServiceAgeEvaluator;
use App\Model\RoomSelectionAdjustment;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -91,6 +92,54 @@ class BookingConfigurator
return $bookingCreateDto; return $bookingCreateDto;
} }
/**
* Brings the room selections back in line with the rooms the travel currently offers.
*
* Room selections are built once when the booking session starts, while the Travel is re-fetched
* from BusPro on every request. A room that sells out or leaves a bookable status in between drops
* out of getAvailableRooms() and leaves a selection behind that points at a room that no longer
* exists; one that only loses part of its contingent leaves a quantity nothing else re-checks
* before the booking is submitted; and one that becomes bookable again has no selection at all.
* Rebuilding the selections from the current rooms, with the chosen quantities carried over and
* capped at what is left, keeps both sides in step.
*
* @return list<RoomSelectionAdjustment> The selections that had to give up quantity, in selection order
*/
public function reconcileRoomSelections(BookingDto $bookingDto): array
{
$availableRooms = $bookingDto->travel->getAvailableRooms();
$adjustments = [];
$quantitiesByRoomId = [];
foreach ($bookingDto->roomSelections as $roomSelection) {
if (null === $roomSelection->id) {
continue;
}
$requestedQuantity = $roomSelection->quantity ?? 0;
$bookableQuantity = min($requestedQuantity, $availableRooms[$roomSelection->id]->available ?? 0);
$quantitiesByRoomId[$roomSelection->id] = $bookableQuantity;
if (0 < $requestedQuantity && $bookableQuantity < $requestedQuantity) {
$adjustments[] = new RoomSelectionAdjustment(
$roomSelection->id,
$roomSelection->label,
$requestedQuantity,
$bookableQuantity
);
}
}
$bookingDto->roomSelections = array_map(
fn (Room $room) => $this->createRoomSelection($room, $quantitiesByRoomId),
$availableRooms
);
return $adjustments;
}
/** /**
* Creates a room selection DTO from room data and quantities. * Creates a room selection DTO from room data and quantities.
* *
+168
View File
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use App\Enum\NewsletterOptInContext;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use Psr\Log\LoggerInterface;
/**
* Decides who a booking may enrol in the newsletter and starts the double opt-in cycles.
*
* The applicant opts in for themselves; everyone else is a third-party sign-up, which is why
* companions are restricted to adults with an address of their own and always get the
* companion wording naming who requested it.
*/
class BookingNewsletterOptIn
{
public function __construct(
private readonly NewsletterManager $newsletterManager,
private readonly LoggerInterface $logger,
) {
}
public function resolveApplicantEmail(BookingDto $bookingDto, ?User $currentUser): ?string
{
$email = null;
if ($currentUser instanceof User) {
$email = $currentUser->getEmail();
}
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
$email = $bookingDto->participants[0]->email;
}
if (null === $email) {
return null;
}
$normalizedEmail = mb_strtolower(trim($email));
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
}
public function resolveApplicantParticipant(BookingDto $bookingDto): ?ParticipantDto
{
$participant = $bookingDto->participants[0] ?? null;
if (null === $participant) {
return null;
}
if (null === $participant->firstName && null === $participant->lastName) {
return null;
}
return $participant;
}
/**
* Companions eligible for a third-party opt-in offer.
*
* Children are excluded because they are allowed to share an adult's address, so subscribing
* them would mail somebody else and would rest on a minor's consent. Deliberately no consent
* lookups here: whether the checkbox appears must not disclose who is already subscribed.
*
* @return list<ParticipantDto>
*/
public function resolveCompanions(BookingDto $bookingDto, ?string $applicantEmail): array
{
$companions = [];
$seenEmails = [];
foreach ($bookingDto->participants as $participant) {
if (true === $participant->isApplicant() || true === $participant->isChild()) {
continue;
}
if (null === $participant->email) {
continue;
}
$normalizedEmail = mb_strtolower(trim($participant->email));
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
continue;
}
if ($normalizedEmail === $applicantEmail || true === isset($seenEmails[$normalizedEmail])) {
continue;
}
$seenEmails[$normalizedEmail] = true;
$companions[] = $participant;
}
return $companions;
}
public function subscribeApplicant(string $email, ?ParticipantDto $applicant): void
{
// Everything here runs after the booking API has accepted the booking, so nothing may
// escape: an exception would reach the controller's generic handler, show a technical
// error and leave the booking session intact, inviting a duplicate booking.
try {
$this->newsletterManager->requestConfirmation(
$email,
$applicant?->firstName,
$applicant?->lastName,
);
} catch (\Throwable $exception) {
$this->logger->warning('Newsletter confirmation request failed after booking', [
'email' => $email,
'error' => $exception->getMessage(),
]);
}
}
/**
* @param list<ParticipantDto> $companions
*/
public function subscribeCompanions(array $companions, ?string $requestedByName): void
{
foreach ($companions as $companion) {
$email = mb_strtolower(trim((string) $companion->email));
// The booking already succeeded, so one unusable address must not stop the others and
// must never surface as a booking failure. The consent lookups are inside the block for
// the same reason: a repository failure there would otherwise reach the controller's
// generic handler and show a technical error for a booking that already exists.
try {
// Skipping a pending request avoids replacing a token the companion asked for
// themselves, which would silently break the link they already hold.
if (true === $this->newsletterManager->hasConfirmedOptIn($email)
|| true === $this->newsletterManager->hasPendingConfirmation($email)) {
continue;
}
$this->newsletterManager->requestConfirmation(
$email,
$companion->firstName,
$companion->lastName,
NewsletterOptInContext::Companion,
$requestedByName,
);
} catch (\Throwable $exception) {
$this->logger->warning('Companion newsletter confirmation request failed after booking', [
'email' => $email,
'error' => $exception->getMessage(),
]);
}
}
}
public function resolveRequestedByName(BookingDto $bookingDto): ?string
{
$applicant = $this->resolveApplicantParticipant($bookingDto);
if (null === $applicant) {
return null;
}
$name = trim(sprintf('%s %s', $applicant->firstName ?? '', $applicant->lastName ?? ''));
return '' !== $name ? $name : null;
}
}
+5 -2
View File
@@ -8,6 +8,7 @@ use App\BusProNet\Constants;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Service; use App\BusProNet\Model\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
@@ -161,7 +162,9 @@ class BookingPricingAssembler
} }
$hasOutboundBus = null !== $participant->transportationOutbound $hasOutboundBus = null !== $participant->transportationOutbound
&& 'BUS' === $participant->transportationOutbound->subType; && DirectionMapper::isBus($participant->transportationOutbound->subType);
$hasInboundBus = null !== $participant->transportationInbound
&& DirectionMapper::isBus($participant->transportationInbound->subType);
if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) { if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
if ($participant->pickup->price < 0) { if ($participant->pickup->price < 0) {
@@ -171,7 +174,7 @@ class BookingPricingAssembler
} }
} }
if (null !== $participant->dropOff && null !== $participant->dropOff->price) { if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) {
if ($participant->dropOff->price < 0) { if ($participant->dropOff->price < 0) {
$participantTransportationDiscountCost += $participant->dropOff->price; $participantTransportationDiscountCost += $participant->dropOff->price;
} else { } else {
+17
View File
@@ -97,6 +97,15 @@ class DatabaseAnonymizer
return $report; return $report;
} }
/**
* Looked up by email alone, never by name: resolveIdentity() would register a name alias,
* and two accounts of the same person — an intended state here — would then collapse onto
* one synthetic identity and so onto one synthetic email, which User::$email is unique on.
*
* The names are only replaced where one is already set. Most accounts carry none until the
* BusPro import fills them in, and minting a name for those would turn an incomplete profile
* into one that looks complete while profileComplete still says otherwise.
*/
public function anonymizeUser(User $user): void public function anonymizeUser(User $user): void
{ {
$identity = $this->resolveIdentity( $identity = $this->resolveIdentity(
@@ -104,6 +113,14 @@ class DatabaseAnonymizer
); );
$user->setEmail($identity['email']); $user->setEmail($identity['email']);
if ($this->hasReplaceableValue($user->getFirstName())) {
$user->setFirstName($identity['firstName']);
}
if ($this->hasReplaceableValue($user->getLastName())) {
$user->setLastName($identity['lastName']);
}
} }
public function anonymizeNewsletterConsent(NewsletterConsent $consent): void public function anonymizeNewsletterConsent(NewsletterConsent $consent): void
+19 -5
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\Email\Mailer; use App\Email\Mailer;
use App\Entity\NewsletterConsent; use App\Entity\NewsletterConsent;
use App\Entity\NewsletterOptInRequest; use App\Entity\NewsletterOptInRequest;
use App\Enum\NewsletterOptInContext;
use App\Exception\NewsletterListNotAllowedException; use App\Exception\NewsletterListNotAllowedException;
use App\Exception\NewsletterProviderException; use App\Exception\NewsletterProviderException;
use App\Mailjet\ApiClient; use App\Mailjet\ApiClient;
@@ -37,8 +38,13 @@ class NewsletterManager
) { ) {
} }
public function requestConfirmation(string $email, ?string $firstName = null, ?string $lastName = null): void public function requestConfirmation(
{ string $email,
?string $firstName = null,
?string $lastName = null,
NewsletterOptInContext $optInContext = NewsletterOptInContext::Own,
?string $requestedByName = null,
): void {
$normalizedEmail = $this->normalizeEmail($email); $normalizedEmail = $this->normalizeEmail($email);
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) { if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
@@ -51,6 +57,8 @@ class NewsletterManager
mailjetListIds: [$this->defaultMailjetListId()], mailjetListIds: [$this->defaultMailjetListId()],
firstName: $this->normalizeName($firstName), firstName: $this->normalizeName($firstName),
lastName: $this->normalizeName($lastName), lastName: $this->normalizeName($lastName),
optInContext: $optInContext,
requestedByName: $this->normalizeName($requestedByName),
); );
} }
@@ -210,7 +218,7 @@ class NewsletterManager
/** /**
* @param list<int|string> $mailjetListIds * @param list<int|string> $mailjetListIds
*/ */
private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null): void private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null, NewsletterOptInContext $optInContext = NewsletterOptInContext::Own, ?string $requestedByName = null): void
{ {
$token = $this->generateToken(); $token = $this->generateToken();
$tokenHash = $this->hashToken($token); $tokenHash = $this->hashToken($token);
@@ -255,11 +263,13 @@ class NewsletterManager
$context = [ $context = [
'token' => $token, 'token' => $token,
'newsletterLists' => $this->createListIdMapping($mailjetListIds), 'newsletterLists' => $this->createListIdMapping($mailjetListIds),
'recipientFirstName' => $firstName,
'requestedByName' => $requestedByName,
]; ];
$options = [ $options = [
'to' => $normalizedEmail, 'to' => $normalizedEmail,
'subject' => 'Deine Anmeldung zum E&P Newsletter', 'subject' => $optInContext->subject(),
'template' => 'email/newsletter_opt_in.html.twig', 'template' => $optInContext->template(),
]; ];
$this->mailer->createAndSendEmail($context, $options); $this->mailer->createAndSendEmail($context, $options);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
@@ -275,9 +285,13 @@ class NewsletterManager
throw new NewsletterProviderException('Could not send newsletter confirmation email.', previous: $exception); throw new NewsletterProviderException('Could not send newsletter confirmation email.', previous: $exception);
} }
// The log line is the provenance record for third-party sign-ups: there is no column
// saying who requested a companion's opt-in, so it has to be recoverable from here.
$this->logger->info('Newsletter confirmation requested', [ $this->logger->info('Newsletter confirmation requested', [
'email' => $normalizedEmail, 'email' => $normalizedEmail,
'expires_at' => $expiresAt->format(DATE_ATOM), 'expires_at' => $expiresAt->format(DATE_ATOM),
'context' => $optInContext->value,
'requested_by' => $requestedByName,
]); ]);
} }
+6 -6
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
@@ -193,18 +194,17 @@ class ParticipantPricingCalculator
} }
} }
// Pickup pricing - only charged when outbound transportation is BUS // Boarding surcharges are charged per leg, at the same rate in both directions
// This matches current BusPro behavior where pickup price is only applied for outbound bus
// Note: priceOutbound/priceInbound are populated for future split pricing support when BusPro is updated
$hasOutboundBus = null !== $participant->transportationOutbound $hasOutboundBus = null !== $participant->transportationOutbound
&& 'BUS' === $participant->transportationOutbound->subType; && DirectionMapper::isBus($participant->transportationOutbound->subType);
$hasInboundBus = null !== $participant->transportationInbound
&& DirectionMapper::isBus($participant->transportationInbound->subType);
if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) { if ($hasOutboundBus && null !== $participant->pickup && null !== $participant->pickup->price) {
$serviceTotal += $participant->pickup->price; $serviceTotal += $participant->pickup->price;
} }
// Drop-off pricing (charged when inbound transportation is BUS and a drop-off is selected) if ($hasInboundBus && null !== $participant->dropOff && null !== $participant->dropOff->price) {
if (null !== $participant->dropOff && null !== $participant->dropOff->price) {
$serviceTotal += $participant->dropOff->price; $serviceTotal += $participant->dropOff->price;
} }
+253
View File
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Traits\TypeConversionTrait;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\TravelExport;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Flattens the BusPro XML export into one row per travel date x hotel, pickups resolved.
*
* Built from the XML file map rather than the travel_snapshot table. The table sits at
* exactly this grain and is tempting, but it is refreshed on its own schedule, independent
* of the master-data caches. Only the loaders share the bpn.cache pool under the xml-sync
* tag, and that is what makes the consistency guarantee hold for free: /api/hotels,
* /api/pickups and this export all move together when app:bpn:xml-sync invalidates the tag.
*
* Each Ziel_*.xml is read and walked exactly once. Looping TravelLoader::loadById() per row
* would re-parse a whole file -- rooms, services, prices -- once per hotel on the date.
*/
class TravelExportBuilder
{
use TypeConversionTrait;
private const CACHE_KEY = 'bpn_travels_export';
public function __construct(
private readonly HotelLoader $hotelLoader,
private readonly PickupLoader $pickupLoader,
private readonly TravelParser $travelParser,
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
) {
}
/**
* Assembles the whole export, cached as one unit alongside the sibling loaders.
*/
public function build(): TravelExport
{
try {
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
return $this->assemble();
});
} catch (InvalidArgumentException $e) {
throw new \RuntimeException('Cache key error in TravelExportBuilder', 0, $e);
}
}
private function assemble(): TravelExport
{
$hotels = $this->hotelLoader->loadAll();
$pickupMasterData = $this->pickupLoader->loadAll();
/** @var array<string, array<string, mixed>> $rows keyed by travelId-hotelId */
$rows = [];
$skipped = 0;
/** @var array<int, true> $pickupsWithoutMasterData */
$pickupsWithoutMasterData = [];
$files = $this
->xmlExport
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
foreach ($files as $file) {
$crawler = XmlCrawlerFactory::create($this->xmlExport->read($file->path()));
$crawler->filterXPath('//reisen/reise/termin')->each(
function (Crawler $node) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $hotels, $pickupMasterData) {
$this->collectTravel($node, $rows, $skipped, $pickupsWithoutMasterData, $hotels, $pickupMasterData);
}
);
}
return new TravelExport(
array_values($rows),
$skipped,
count($pickupsWithoutMasterData),
new \DateTimeImmutable(),
);
}
/**
* @param array<string, array<string, mixed>> $rows
* @param array<int, true> $pickupsWithoutMasterData
* @param array<int, Hotel> $hotels
* @param array<int|string, Pickup> $pickupMasterData
*/
private function collectTravel(
Crawler $node,
array &$rows,
int &$skipped,
array &$pickupsWithoutMasterData,
array $hotels,
array $pickupMasterData,
): void {
$hotelNodes = $node->filterXPath('.//hotel');
// Dates first: getPickups() needs a non-null default date for the time-only zeit
// shape, and parsing them up front is also what turns an unusable date into a
// dropped row rather than a row with a null date the consumer cannot key on.
$dateFrom = $this->stringToDate($node->attr('termin'));
$dateTo = $this->stringToDate($node->attr('bis'));
if (null === $dateFrom || null === $dateTo) {
$this->logger->warning('Travel export: unparseable travel date', [
'travel_id' => $node->attr('idbuspro'),
'termin' => $node->attr('termin'),
'bis' => $node->attr('bis'),
]);
$skipped += $hotelNodes->count();
return;
}
$travelId = (int) $node->attr('idbuspro');
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
// calendar day. Resolved once per travel date, then shared by its hotel rows.
$pickups = $this->buildPickups(
$this->travelParser->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true),
$pickupMasterData,
$pickupsWithoutMasterData,
);
$travel = [
'busProId' => $travelId,
'code' => $node->attr('code'),
'product' => $this->trimOrNull($this->getStringOrNullValue($node->filterXPath('.//text'))),
'dateFrom' => $dateFrom->format('Y-m-d'),
'dateTo' => $dateTo->format('Y-m-d'),
];
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, &$skipped, $hotels, $travel, $travelId, $pickups) {
$hotelId = (int) $hotelNode->attr('idbuspro');
// Defensive: no travel in the current snapshot references a hotel that
// hotel.xml does not carry. A row without master data would reach the consumer
// with no name and no country, so it is dropped and counted instead.
if (false === isset($hotels[$hotelId])) {
$this->logger->warning('Travel export: hotel missing from master data', [
'travel_id' => $travelId,
'hotel_id' => $hotelId,
]);
++$skipped;
return;
}
$hotel = $hotels[$hotelId];
// Keyed by the pair the consumer upserts on, so a date repeated across files
// collapses last-wins instead of reaching it as a duplicate.
$rows[$travelId.'-'.$hotelId] = $travel + [
'hotel' => [
'busProId' => $hotelId,
'code' => $hotel->code,
'name' => $hotel->name,
'costUnit' => $hotel->costUnit,
'country' => $hotel->country,
'countryCode' => $hotel->countryCode,
],
'pickups' => $pickups,
];
});
}
/**
* Joins the travel XML's pickups against zustiege.xml.
*
* Deliberately not PickupLoader::patchPickupsDetails(), which sorts by time -- the
* contract is source order, which is the order the disposition lists them in. Nor
* enrichPickupDetails(), which is private and passes over a miss silently.
*
* @param array<int, Pickup> $pickups pickups indexed by id, in source order
* @param array<int|string, Pickup> $masterData
* @param array<int, true> $pickupsWithoutMasterData
*
* @return list<array<string, mixed>>
*/
private function buildPickups(array $pickups, array $masterData, array &$pickupsWithoutMasterData): array
{
$resolved = [];
foreach ($pickups as $pickup) {
$pickupId = $pickup->id;
// Defensive: none exist today. An entry with no id is not addressable by the
// consumer, so drop the entry -- never the row it belongs to.
if (null === $pickupId || 0 === $pickupId) {
continue;
}
$details = $masterData[$pickupId] ?? null;
// Around 21 ids referenced by travel XML have no entry in zustiege.xml. The
// entry is emitted anyway: dropping it would hide a real departure point from
// the disposition UI, where a nameless stop is still better than a missing one.
if (null === $details) {
$pickupsWithoutMasterData[$pickupId] = true;
}
$resolved[] = [
'busProId' => $pickupId,
'code' => $details?->code,
'city' => $details?->city,
'street' => $details?->street,
'time' => $pickup->time?->format('H:i'),
// Emitted for time-only sources too: adjustOvernightTimes() has already
// worked out the real day, and a null here would throw that away.
'datetime' => $pickup->time?->format('c'),
];
}
return $resolved;
}
private function getStringOrNullValue(Crawler $node): ?string
{
return 0 < $node->count() ? $node->text() : null;
}
private function trimOrNull(?string $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim($value);
return '' === $trimmed ? null : $trimmed;
}
}
+1 -13
View File
@@ -1,18 +1,6 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a href="{{ path('app_account') }}" title="zu meinem Account"> <a href="{{ path('app_account') }}" title="zu meinem Account">
{# The operating entity depends on where the accommodation is, so its logo wins over the host's #} {% include '_partials/_logo.html.twig' %}
{% set country_logo = accommodation|default(null) and accommodation.country ? accommodation.country.logo : null %}
{% if country_logo %}
<img src="{{ asset('build/images/' ~ country_logo) }}" alt="" class="h-14 my-1 w-auto">
{% elseif booking_theme() == 'sbw' %}
<img src="{{ asset('build/images/logo_sbw.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'ser' %}
<img src="{{ asset('build/images/logo_ser.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'snz' %}
<img src="{{ asset('build/images/logo_snz.svg') }}" alt="" class="h-10 my-2 w-auto">
{% else %}
<img src="{{ asset('build/images/logo_ep.svg') }}" alt="" class="h-10 my-2 w-auto">
{% endif %}
</a> </a>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{% if is_granted('ROLE_USER') %} {% if is_granted('ROLE_USER') %}
+47
View File
@@ -0,0 +1,47 @@
{# The login form itself, shared by security/login.html.twig and security/login_oauth2.html.twig so
the field names, the CSRF token id and the error rendering cannot drift between the two pages.
Expects `error` and `last_username` from the including template's context. #}
{% if error %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %}
<div class="pb-4">
<form action="{{ path('app_login') }}" method="post">
<div class="mb-4">
<label for="username" class="mb-1 font-semibold text-white">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="form-field"
required
autocomplete="username">
</div>
<div class="mb-4" {{ stimulus_controller('password-reveal') }}>
<label for="password" class="mb-1 font-semibold text-white">
Passwort:
</label>
<div class="relative">
<input type="password"
id="password"
name="_password"
class="form-field"
{{ stimulus_target('password-reveal', 'field') }}
required
autocomplete="current-password">
<button type="button"
{{ stimulus_action('password-reveal', 'toggle') }}
class="absolute top-0 right-0 mt-2 mr-2">
{{ icon('eye', 'w-6 h-6 text-gray-500') }}
</button>
</div>
</div>
<div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2 mt-4">
<button type="submit" class="button button--primary">
Login
</button>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
</div>
+15
View File
@@ -0,0 +1,15 @@
{# The brand logo, extracted from _header.html.twig so the OAuth2 login chrome can show the same
logo without inheriting the account links that go with it. Takes an optional `accommodation`. #}
{# The operating entity depends on where the accommodation is, so its logo wins over the host's #}
{% set country_logo = accommodation|default(null) and accommodation.country ? accommodation.country.logo : null %}
{% if country_logo %}
<img src="{{ asset('build/images/' ~ country_logo) }}" alt="" class="h-14 my-1 w-auto">
{% elseif booking_theme() == 'sbw' %}
<img src="{{ asset('build/images/logo_sbw.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'ser' %}
<img src="{{ asset('build/images/logo_ser.svg') }}" alt="" class="h-10 my-2 w-auto">
{% elseif booking_theme() == 'snz' %}
<img src="{{ asset('build/images/logo_snz.svg') }}" alt="" class="h-10 my-2 w-auto">
{% else %}
<img src="{{ asset('build/images/logo_ep.svg') }}" alt="" class="h-10 my-2 w-auto">
{% endif %}
@@ -97,6 +97,11 @@
<twig:dropdown:link url="{{ edit_url }}"> <twig:dropdown:link url="{{ edit_url }}">
bearbeiten bearbeiten
</twig:dropdown:link> </twig:dropdown:link>
{# Copying leaves the original alone, so it is offered in every
status — see DuplicateController. #}
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_duplicate', { 'id': booking.id, 'r': return_url() }) }}">
duplizieren
</twig:dropdown:hxbutton>
{% if booking.confirmed %} {% if booking.confirmed %}
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}"> <twig:dropdown:link url="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}">
PDF herunterladen PDF herunterladen
@@ -0,0 +1,15 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}{{ booking.recordLabel }} kopieren{% endblock %}
{% block content %}
<div>
Möchtest du {{ booking.acceptedAt is not null ? 'die Buchung' : 'das Angebot' }} für
<em>{{ booking.groupName }}</em> kopieren?
Die Kopie übernimmt Gruppenhaus, Zeitraum, Personen, Leistungen, Rabatte und den
berechneten Preis unverändert und wird als neuer Entwurf angelegt. Das Original bleibt
unverändert, und die Kopie bekommt einen eigenen Zugangslink.
</div>
{% endblock %}
{% block button_confirm %}kopieren{% endblock %}
@@ -291,6 +291,13 @@
{% if app.request.query.get('r') %} {% if app.request.query.get('r') %}
{% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %} {% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %}
{% endif %} {% endif %}
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_duplicate', edit_params) }}"
hx-target="body"
hx-swap="beforeend">
duplizieren
</button>
<a href="{{ path('app_admin_accommodationbooking_edit', edit_params) }}" class="button button--primary button--small"> <a href="{{ path('app_admin_accommodationbooking_edit', edit_params) }}" class="button button--primary button--small">
bearbeiten bearbeiten
</a> </a>
@@ -486,6 +486,11 @@
'label_attr': {'class': 'text-base font-semibold'} 'label_attr': {'class': 'text-base font-semibold'}
}) }} }) }}
{% endif %} {% endif %}
{% if form.companionNewsletterOptIn is defined and companionNewsletterOptInVisible %}
{{ form_row(form.companionNewsletterOptIn, {
'label_attr': {'class': 'text-base font-semibold'}
}) }}
{% endif %}
{{ form_row(form.confirmationAccepted, { {{ form_row(form.confirmationAccepted, {
'label_attr': {'class': 'text-base font-semibold'} 'label_attr': {'class': 'text-base font-semibold'}
}) }} }) }}
@@ -0,0 +1,33 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo{% if recipientFirstName %} {{ recipientFirstName }}{% endif %}!
</h1>
<p>
{% if requestedByName %}{{ requestedByName }} hat{% else %}Die anmeldende Person hat{% endif %}
bei der Buchung eurer gemeinsamen Reise angegeben, dass du unseren Newsletter erhalten möchtest.
</p>
{% if newsletterLists is defined and newsletterLists is not empty %}
<p>
Bitte bestätige selbst, dass du die folgende Newsletter erhalten möchtest:
{% for newsletterList in newsletterLists %}
<br>
- {{ newsletterList.label }}
{% endfor %}
</p>
{% endif %}
<p>
<a href="{{ url('app_newsletter_confirm', { 'token': token }) }}" class="button">
Newsletter-Anmeldung bestätigen
</a>
</p>
<p>
Wenn du dich nicht zum Newsletter anmelden möchtest, kannst du diese E-Mail einfach ignorieren und löschen.
</p>
<p>
<a href="{{ url('app_login') }}" class="button">
Zu MyE&P
</a>
</p>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{# Chrome for a login that is serving an external OAuth2 authorization.
Only the logo survives from the normal header: the account and logout links are dead ends in the
middle of an authorization, and the marketing column of the standard login page is noise when the
user did not come here to read about MyE&P. The footer's legal links stay -- this page collects
credentials, so dropping them would be the wrong direction.
The background is inherited unchanged, which is why the shared form partial's `text-white`
styling still works here. #}
{% extends 'layout.html.twig' %}
{% block header %}
{% include '_partials/_logo.html.twig' %}
{% endblock %}
+1 -44
View File
@@ -7,50 +7,7 @@
Login<br>MyE&amp;P Login<br>MyE&amp;P
</h1> </h1>
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
{% if error %} {% include '_partials/_login_form.html.twig' %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %}
<div class="pb-4">
<form action="{{ path('app_login') }}" method="post" hx-history="false">
<div class="mb-4">
<label for="username" class="mb-1 font-semibold text-white">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="form-field"
required
autocomplete="username">
</div>
<div class="mb-4" {{ stimulus_controller('password-reveal') }}>
<label for="password" class="mb-1 font-semibold text-white">
Passwort:
</label>
<div class="relative">
<input type="password"
id="password"
name="_password"
class="form-field"
{{ stimulus_target('password-reveal', 'field') }}
required
autocomplete="current-password">
<button type="button"
{{ stimulus_action('password-reveal', 'toggle') }}
class="absolute top-0 right-0 mt-2 mr-2">
{{ icon('eye', 'w-6 h-6 text-gray-500') }}
</button>
</div>
</div>
<div class="flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-2 mt-4">
<button type="submit" class="button button--primary">
Login
</button>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
</div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<a href="{{ path('app_reset_password') }}" <a href="{{ path('app_reset_password') }}"
class="text-white underline"> class="text-white underline">
+37
View File
@@ -0,0 +1,37 @@
{# The login page as seen by someone who was sent here by another E&P application (ephub, myep-team)
to authorize it. Its job is to explain why they are suddenly on a different website and that they
will be sent back, which the standard login page never said.
The grid is the one from security/login.html.twig, minus its marketing column: that keeps the
form column at exactly the width it has on the standard login page. #}
{% extends 'layout_oauth2.html.twig' %}
{% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
<div>
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
{% if client_name %}
Anmelden für<br>{{ client_name }}
{% else %}
Login<br>MyE&amp;P
{% endif %}
</h1>
<p class="mb-6 text-white">
Melde dich mit deinem MyE&amp;P-Konto an. Anschließend wirst du automatisch
{% if client_name %}zu {{ client_name }} {% endif %}zurückgeleitet.
</p>
{% include '_partials/_login_form.html.twig' %}
<div class="flex space-x-2">
<a href="{{ path('app_reset_password') }}" class="text-white underline">
Passwort vergessen?
</a>
<a href="{{ path('app_registration') }}" class="text-white underline">
Jetzt registrieren
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,23 @@
{% extends 'layout.html.twig' %}
{% block title %}Kein Zugriff{% endblock %}
{% block content %}
<div class="px-4 lg:px-8 py-8 lg:py-16 max-w-2xl">
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
Kein Zugriff
</h1>
<p class="mb-4 text-white">
Du bist bei MyE&amp;P angemeldet, aber dein Konto ist nicht für
<strong>{{ client }}</strong> freigeschaltet.
</p>
<p class="mb-8 text-white">
Das liegt an den Berechtigungen deines Kontos, nicht an deinem Passwort &ndash;
ein erneuter Login ändert daran nichts. Wenn du hier Zugriff brauchst, wende
dich bitte an dein E&amp;P-Team.
</p>
<a href="{{ path('app_account') }}" class="button button--primary">
Zurück zu MyE&amp;P
</a>
</div>
{% endblock %}
@@ -3,9 +3,11 @@
namespace App\Tests\BusProNet\DataLoader; namespace App\Tests\BusProNet\DataLoader;
use App\BusProNet\Model\Hotel; use App\BusProNet\Model\Hotel;
use App\BusProNet\Utility\CountryCodeMapper;
use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\HotelLoader;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter; use Symfony\Component\Cache\Adapter\TagAwareAdapter;
@@ -34,7 +36,7 @@ class HotelDataLoaderTest extends TestCase
->willReturn($xmlContent); ->willReturn($xmlContent);
$cache = new TagAwareAdapter(new ArrayAdapter()); $cache = new TagAwareAdapter(new ArrayAdapter());
$loader = new HotelLoader($cache, $filesystem); $loader = new HotelLoader(new CountryCodeMapper(new NullLogger()), $cache, $filesystem);
$hotels = $loader->loadAll(); $hotels = $loader->loadAll();
@@ -50,5 +52,7 @@ class HotelDataLoaderTest extends TestCase
$this->assertEquals('F', $hotel->country); $this->assertEquals('F', $hotel->country);
$this->assertEquals('Rue des Lacs', $hotel->street); $this->assertEquals('Rue des Lacs', $hotel->street);
$this->assertEquals('', $hotel->phone); $this->assertEquals('', $hotel->phone);
$this->assertEquals('fr', $hotel->countryCode);
$this->assertNull($hotel->costUnit);
} }
} }
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\Utility;
use App\BusProNet\Utility\CountryCodeMapper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
class CountryCodeMapperTest extends TestCase
{
/**
* @return array<string, array{string, string}>
*/
public static function tokenProvider(): array
{
return [
'iso-2 passes through' => ['CH', 'ch'],
'vehicle code D' => ['D', 'de'],
'iso-3 DEU' => ['DEU', 'de'],
'vehicle code A' => ['A', 'at'],
'iso-2 AT' => ['AT', 'at'],
'iso-3 AUT' => ['AUT', 'at'],
'vehicle code F' => ['F', 'fr'],
'vehicle code I' => ['I', 'it'],
'iso-2 IT' => ['IT', 'it'],
'vehicle code E' => ['E', 'es'],
'vehicle code B' => ['B', 'be'],
'vehicle code S is Sweden' => ['S', 'se'],
'vehicle code N is Norway' => ['N', 'no'],
'vehicle code FL is Liechtenstein' => ['FL', 'li'],
'IR is Ireland, not Iran' => ['IR', 'ie'],
'UK maps to the iso GB' => ['UK', 'gb'],
'iso-3 PRT' => ['PRT', 'pt'],
'iso-3 GRE' => ['GRE', 'gr'],
'iso-3 HRV' => ['HRV', 'hr'],
'iso-3 RUS' => ['RUS', 'ru'],
'iso-3 USA' => ['USA', 'us'],
'SI is Slovenia' => ['SI', 'si'],
'SLO is Slovakia' => ['SLO', 'sk'],
'ZA is South Africa' => ['ZA', 'za'],
];
}
#[DataProvider('tokenProvider')]
public function testMapsKnownToken(string $token, string $expected): void
{
$this->assertSame($expected, (new CountryCodeMapper(new NullLogger()))->map($token));
}
public function testEveryMappedCodeIsLowercaseIsoTwo(): void
{
$mapper = new CountryCodeMapper(new NullLogger());
foreach (array_keys(self::tokenProvider()) as $name) {
[$token] = self::tokenProvider()[$name];
$code = $mapper->map($token);
$this->assertNotNull($code);
$this->assertMatchesRegularExpression('/^[a-z]{2}$/', $code, $token.' must map to lowercase ISO-2');
}
}
public function testIsCaseAndWhitespaceInsensitive(): void
{
$mapper = new CountryCodeMapper(new NullLogger());
$this->assertSame('ch', $mapper->map('ch'));
$this->assertSame('at', $mapper->map(' aut '));
}
public function testUnknownTokenMapsToNullAndWarnsOnce(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger
->expects($this->once())
->method('warning')
->with('Unmapped BusPro country token', ['token' => 'XX']);
$mapper = new CountryCodeMapper($logger);
$this->assertNull($mapper->map('XX'));
$this->assertNull($mapper->map('XX'));
}
public function testAbsentTokenMapsToNullWithoutWarning(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger
->expects($this->never())
->method('warning');
$mapper = new CountryCodeMapper($logger);
$this->assertNull($mapper->map(null));
$this->assertNull($mapper->map(''));
$this->assertNull($mapper->map(' '));
}
}
+65 -1
View File
@@ -5,10 +5,14 @@ declare(strict_types=1);
namespace App\Tests\BusProNet\XmlLoader; namespace App\Tests\BusProNet\XmlLoader;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\CountryCodeMapper;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\HotelLoader;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\DomCrawler\Crawler;
class HotelLoaderTest extends TestCase class HotelLoaderTest extends TestCase
{ {
@@ -19,7 +23,7 @@ class HotelLoaderTest extends TestCase
->expects($this->never()) ->expects($this->never())
->method('read'); ->method('read');
$loader = new HotelLoader(new ArrayAdapter(), $filesystem); $loader = $this->createLoader($filesystem);
$travel = new Travel(); $travel = new Travel();
$travel->hotelId = null; $travel->hotelId = null;
$travel->hotel = null; $travel->hotel = null;
@@ -28,4 +32,64 @@ class HotelLoaderTest extends TestCase
$this->assertNull($travel->hotel); $this->assertNull($travel->hotel);
} }
public function testParseXmlMapsCostUnitAndCountryCode(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode(
'<kostenstelle>83</kostenstelle><land>CH</land>'
));
$this->assertSame('83', $hotel->costUnit);
$this->assertSame('CH', $hotel->country, 'the raw token is the join key and must not be rewritten');
$this->assertSame('ch', $hotel->countryCode);
}
public function testParseXmlKeepsAListOfCostUnitsAsAString(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode(
'<kostenstelle>84 85</kostenstelle><land>D</land>'
));
$this->assertSame('84 85', $hotel->costUnit);
}
public function testParseXmlLeavesCostUnitNullWhenAbsent(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode('<land>D</land>'));
$this->assertNull($hotel->costUnit);
$this->assertSame('de', $hotel->countryCode);
}
public function testParseXmlLeavesBothCountryFieldsNullWhenLandIsAbsent(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode('<name>Ohne Land</name>'));
$this->assertNull($hotel->country);
$this->assertNull($hotel->countryCode);
}
public function testParseXmlLeavesCountryCodeNullForAnUnmappableToken(): void
{
$hotel = $this->createLoader()->parseXml($this->hotelNode('<land>XX</land>'));
$this->assertSame('XX', $hotel->country);
$this->assertNull($hotel->countryCode, 'an unknown token must never be guessed into a code');
}
private function hotelNode(string $children): Crawler
{
$xml = sprintf('<hotels><hotel idbuspro="1" code="TEST">%s</hotel></hotels>', $children);
return XmlCrawlerFactory::create($xml)->filterXPath('//hotels/hotel');
}
private function createLoader(?FilesystemOperator $filesystem = null): HotelLoader
{
return new HotelLoader(
new CountryCodeMapper(new NullLogger()),
new ArrayAdapter(),
$filesystem ?? $this->createStub(FilesystemOperator::class),
);
}
} }
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlLoader;
use App\BusProNet\Model\Hotel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\BusProNet\XmlParser\TravelParser;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FileAttributes;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
class TravelLoaderFilesMapTest extends TestCase
{
private const XML = '<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise code="SBW">
<termin idbuspro="900" code="SBW250101" termin="01.01.2025" bis="08.01.2025">
<text>Skiwoche</text>
<hotel idbuspro="1" code="KNOWN"/>
<hotel idbuspro="999" code="GHOST"/>
</termin>
</reise>
</reisen>';
/**
* A travel may reference a hotel id that hotel.xml does not carry. Before the guard the
* miss was dereferenced, which fataled the whole cached mapping rather than one entry --
* so /api/travels went down entirely, not just the affected travel.
*/
public function testGenerateFilesMapSkipsHotelsMissingFromMasterData(): void
{
$known = new Hotel();
$known->id = 1;
$known->name = 'Hotel Bekannt';
$hotelLoader = $this->createStub(HotelLoader::class);
$hotelLoader->method('loadAll')->willReturn([1 => $known]);
$filesystem = $this->createStub(FilesystemOperator::class);
$filesystem->method('listContents')->willReturn(
new DirectoryListing([new FileAttributes('Ziel_1.xml')])
);
$filesystem->method('read')->willReturn(self::XML);
$loader = new TravelLoader(
$hotelLoader,
$this->createStub(TravelParser::class),
'https://example.test',
new TagAwareAdapter(new ArrayAdapter()),
$filesystem,
);
$mapping = $loader->generateFilesMap();
$this->assertArrayHasKey(900, $mapping);
$this->assertSame([1], array_keys($mapping[900]['hotels']));
$this->assertSame('Hotel Bekannt', $mapping[900]['hotels'][1]['label']);
}
}
@@ -7,6 +7,7 @@ namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\ApiClient; use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ResponseParserException; use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\XmlParser\ApiResponseParser; use App\BusProNet\XmlParser\ApiResponseParser;
use App\BusProNet\XmlParser\TravelParser;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -22,7 +23,7 @@ class ApiResponseParserFailureTest extends TestCase
$this->expectException(ResponseParserException::class); $this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('empty response (0 bytes)'); $this->expectExceptionMessage('empty response (0 bytes)');
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, ''); $this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, '');
} }
public function testTruncatedResponseNamesTheLibxmlReason(): void public function testTruncatedResponseNamesTheLibxmlReason(): void
@@ -30,7 +31,7 @@ class ApiResponseParserFailureTest extends TestCase
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="HINWEIS"></satz><hinweis'; $xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="HINWEIS"></satz><hinweis';
try { try {
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml); $this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
$this->fail('Expected a ResponseParserException'); $this->fail('Expected a ResponseParserException');
} catch (ResponseParserException $e) { } catch (ResponseParserException $e) {
self::assertStringContainsString('('.strlen($xml).' bytes)', $e->getMessage()); self::assertStringContainsString('('.strlen($xml).' bytes)', $e->getMessage());
@@ -45,7 +46,7 @@ class ApiResponseParserFailureTest extends TestCase
$this->expectException(ResponseParserException::class); $this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised BusProNet response type "IRGENDWAS"'); $this->expectExceptionMessage('Unrecognised BusProNet response type "IRGENDWAS"');
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml); $this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
} }
public function testUnknownCustomerDataSubtype(): void public function testUnknownCustomerDataSubtype(): void
@@ -55,7 +56,7 @@ class ApiResponseParserFailureTest extends TestCase
$this->expectException(ResponseParserException::class); $this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised customer data subtype "Irgendwas"'); $this->expectExceptionMessage('Unrecognised customer data subtype "Irgendwas"');
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $xml); $this->createParser()->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $xml);
} }
public function testDiagnosingDoesNotLeakLibxmlErrorState(): void public function testDiagnosingDoesNotLeakLibxmlErrorState(): void
@@ -64,7 +65,7 @@ class ApiResponseParserFailureTest extends TestCase
try { try {
try { try {
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, '<ergebnis'); $this->createParser()->parseXmlString(ApiClient::TYPE_NOTIFICATION, '<ergebnis');
} catch (ResponseParserException) { } catch (ResponseParserException) {
} }
@@ -74,4 +75,12 @@ class ApiResponseParserFailureTest extends TestCase
libxml_use_internal_errors($previous); libxml_use_internal_errors($previous);
} }
} }
/**
* None of these responses reach the travel parser; a stub keeps the dependency out of view.
*/
private function createParser(): ApiResponseParser
{
return new ApiResponseParser($this->createStub(TravelParser::class));
}
} }
@@ -47,6 +47,26 @@ class CrmAttributesResponseParserTest extends TestCase
self::assertContains('ROLE_CUSTOMER_EXPERT', $roles); self::assertContains('ROLE_CUSTOMER_EXPERT', $roles);
} }
/**
* ROLE_TEAM_ADMIN is not consumed here at all — it is exported through /api/userinfo and is
* myep-team's administrative role — so the claim is the only thing standing between the CRM
* selection and that application.
*/
public function testParseAssignsTeamAdminRoleWhenSelected(): void
{
$roles = $this->parseRoles($this->selectionXml(1484, true));
self::assertContains('ROLE_TEAM_ADMIN', $roles);
self::assertNotContains('ROLE_ADMIN', $roles, 'the "Admin" selection 1292 is a different one');
}
public function testParseAssignsNoTeamAdminRoleWhenNotSelected(): void
{
$roles = $this->parseRoles($this->selectionXml(1484, false));
self::assertNotContains('ROLE_TEAM_ADMIN', $roles);
}
public function testParseAssignsNoCustomerExpertRoleWhenNotSelected(): void public function testParseAssignsNoCustomerExpertRoleWhenNotSelected(): void
{ {
$roles = $this->parseRoles($this->selectionXml(1483, false)); $roles = $this->parseRoles($this->selectionXml(1483, false));
+96 -1
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser; namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\TravelParser; use App\BusProNet\XmlParser\TravelParser;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -15,7 +17,7 @@ class TravelParserTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->parser = new TravelParser(); $this->parser = $this->createParser();
} }
public function testParseServiceDescriptions(): void public function testParseServiceDescriptions(): void
@@ -355,6 +357,99 @@ class TravelParserTest extends TestCase
$this->assertNull($travel->status); // No status_hin node should result in null $this->assertNull($travel->status); // No status_hin node should result in null
} }
public function testDropOffsInheritThePickupPriceWhenTheReturnLegStatesNone(): void
{
// Shape of product 701: the return boarding points carry no preis although
// BusPro bills an Abfahrtsortzuschlag for the return leg as well.
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />
<zustieg idbuspro="27" zeit="03:45" />',
'<zustieg_rueck idbuspro="18" />
<zustieg_rueck idbuspro="27" />'
);
$this->assertSame(17.9, $travel->dropOffs[18]->price);
$this->assertSame(17.9, $travel->dropOffs[18]->priceInbound);
$this->assertSame(17.9, $travel->pickups[18]->priceInbound);
// A point that is free outbound stays free on the return leg
$this->assertNull($travel->dropOffs[27]->price);
$this->assertNull($travel->pickups[27]->priceInbound);
}
public function testPickupInheritsTheDropOffPriceWhenTheOutboundLegStatesNone(): void
{
// The mirror image of the product 701 defect: an asymmetric pair is a defect
// whichever leg is missing, so the stated price applies to both.
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" />',
'<zustieg_rueck idbuspro="18" preis="17,90" />'
);
$this->assertSame(17.9, $travel->pickups[18]->price);
$this->assertSame(17.9, $travel->pickups[18]->priceOutbound);
$this->assertSame(17.9, $travel->dropOffs[18]->price);
}
public function testConflictingBoardingPricesResolveToTheOutboundOne(): void
{
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />',
'<zustieg_rueck idbuspro="18" preis="9,90" />'
);
$this->assertSame(17.9, $travel->pickups[18]->price);
$this->assertSame(17.9, $travel->dropOffs[18]->price);
}
public function testDropOffWithoutAPickupCounterpartKeepsItsOwnPrice(): void
{
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />',
'<zustieg_rueck idbuspro="99" preis="5,90" />'
);
$this->assertSame(5.9, $travel->dropOffs[99]->price);
}
public function testBoardingPricesAreReportedVerbatimWhenLevellingIsDisabled(): void
{
// The levelling compensates a vendor export defect and is therefore switchable.
// With it off the product 701 shape undercharges the return leg again.
$travel = $this->parseTravelWithBoardingPoints(
'<zustieg idbuspro="18" zeit="12.12.2026 21:45:00" preis="17,90" />',
'<zustieg_rueck idbuspro="18" />',
$this->createParser(levelBoardingPointPrices: false)
);
$this->assertSame(17.9, $travel->pickups[18]->price);
$this->assertNull($travel->pickups[18]->priceInbound);
$this->assertNull($travel->dropOffs[18]->price);
}
private function createParser(bool $levelBoardingPointPrices = true): TravelParser
{
return new TravelParser(new AgeConstraintParserRegistry(), $levelBoardingPointPrices);
}
private function parseTravelWithBoardingPoints(string $pickups, string $dropOffs, ?TravelParser $parser = null): Travel
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise id="1" idbuspro="701" code="MVKMP" erstellt_am="28.09.2026 12:29:00">
<termin id="1" idbuspro="12939" idprodukt="701" termin="13.12.2026" bis="18.12.2026" code="MVKMP131226" reiseart="F">
<text>Test Travel</text>
<zustiege>'.$pickups.'</zustiege>
<zustiege_rueck>'.$dropOffs.'</zustiege_rueck>
</termin>
</reise>
</reisen>';
$crawler = XmlCrawlerFactory::create($xmlContent);
return ($parser ?? $this->parser)->parse($crawler->filterXPath('//reise/termin')->first());
}
public function testGetPickupsAdjustsOvernightTimes(): void public function testGetPickupsAdjustsOvernightTimes(): void
{ {
$xml = '<?xml version="1.0" encoding="utf-8"?> $xml = '<?xml version="1.0" encoding="utf-8"?>
@@ -8,6 +8,7 @@ use App\Controller\Admin\AccommodationBooking\ChangeAccommodationController;
use App\Entity\Groups\Accommodation; 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\Exception\ImplausibleBookingPriceException;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -87,6 +88,36 @@ class ChangeAccommodationControllerTest extends TestCase
self::assertSame('success', $controller->flashes[0]['type']); self::assertSame('success', $controller->flashes[0]['type']);
} }
public function testAHouseThatPricesImplausiblyIsReportedInTheModal(): void
{
// The service leaves the booking at its old house and rethrows; the office has to see why
// the change did not happen, on the modal it just submitted, not as an error page.
$booking = $this->draft();
$bookingService = $this->createStub(AccommodationBookingService::class);
$bookingService->method('changeAccommodation')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$field = $this->createStub(FormInterface::class);
$field->method('getData')->willReturn(new Accommodation());
$form = $this->createMock(FormInterface::class);
$form->method('handleRequest')->willReturn($form);
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn(true);
$form->method('get')->with('accommodation')->willReturn($field);
$form->expects(self::once())->method('addError');
$controller = $this->buildController($bookingService, $form);
$response = $controller->index($booking, $this->request('POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertFalse($response->headers->has('HX-Redirect'), 'the modal stays open on the error');
self::assertSame('admin/accommodation_booking/modal_change_accommodation.html.twig', $controller->renderedView);
self::assertSame([], $controller->flashes, 'nothing was changed, so nothing is reported as success');
}
public function testPickingTheSameHouseIsNotReportedAsAChange(): void public function testPickingTheSameHouseIsNotReportedAsAChange(): void
{ {
$booking = $this->draft(); $booking = $this->draft();
@@ -7,6 +7,7 @@ namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\CreateController; use App\Controller\Admin\AccommodationBooking\CreateController;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Entity\User; use App\Entity\User;
use App\Exception\ImplausibleBookingPriceException;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -32,6 +33,37 @@ class CreateControllerTest extends TestCase
self::assertSame($user, $controller->capturedBooking?->getManagedBy()); self::assertSame($user, $controller->capturedBooking?->getManagedBy());
} }
public function testAnImplausiblePriceComesBackAsAFormErrorWithoutSaving(): void
{
// Same as on the edit form: the fix is in the house's price rows, so the office is told
// on the form it is standing in front of, and nothing is written.
$bookingService = $this->createStub(AccommodationBookingService::class);
$bookingService->method('refreshPriceSnapshot')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$form = $this->createMock(FormInterface::class);
$form->method('handleRequest')->willReturn($form);
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn(true);
$form->expects(self::once())->method('addError');
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('persist');
$entityManager->expects(self::never())->method('flush');
$controller = new TestableCreateController(
$entityManager,
$this->createStub(LoggerInterface::class),
$bookingService,
$form,
new User('[email protected]'),
);
$response = $controller->index(Request::create('/admin/accommodation-booking/create', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), 'the modal is re-rendered, not redirected');
}
private function buildController(?UserInterface $user): TestableCreateController private function buildController(?UserInterface $user): TestableCreateController
{ {
$form = $this->createStub(FormInterface::class); $form = $this->createStub(FormInterface::class);
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\DuplicateController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* Covers the guards around copying — the copy itself is tested in AccommodationBookingTest
* and AccommodationBookingServiceTest.
*/
class DuplicateControllerTest extends TestCase
{
public function testGetRendersTheConfirmationModal(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('duplicate');
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
$response = $controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('admin/accommodation_booking/modal_duplicate.html.twig', $controller->renderedView);
}
/**
* Copying does not touch the original, so unlike absagen and löschen it is offered in
* every status — "same group, next season" starts from a confirmed booking.
*/
#[DataProvider('everyStatus')]
public function testABookingMayBeCopiedInAnyStatus(AccommodationBookingStatus $status): void
{
$booking = $this->openBooking();
$booking->setStatus($status);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())
->method('duplicate')
->with($booking)
->willReturn(new AccommodationBooking());
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/duplicate', 'POST'));
self::assertTrue($response->headers->has('HX-Redirect'));
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function everyStatus(): iterable
{
foreach (AccommodationBookingStatus::cases() as $status) {
yield $status->value => [$status];
}
}
public function testTheBrowserIsSentToTheCopyAndKeepsTheReturnUrl(): void
{
$bookingService = $this->createStub(AccommodationBookingService::class);
$bookingService->method('duplicate')->willReturn(new AccommodationBooking());
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
$response = $controller->index(
$this->openBooking(),
Request::create('/admin/accommodation-booking/1/duplicate?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2', 'POST'),
);
$location = (string) $response->headers->get('HX-Redirect');
self::assertStringContainsString('app_admin_accommodationbooking_edit', $location);
self::assertStringContainsString('r=', $location);
}
public function testPostWithAnInvalidTokenIsDenied(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('duplicate');
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class), tokenValid: false);
$this->expectException(AccessDeniedException::class);
$controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate', 'POST'));
}
private function openBooking(): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setGroupName('Schulklasse 7b');
$booking->setEmail('[email protected]');
return $booking;
}
}
final class TestableDuplicateController extends DuplicateController
{
public ?string $renderedView = null;
/** @var list<array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
AccommodationBookingService $bookingService,
LoggerInterface $logger,
private readonly bool $tokenValid = true,
) {
parent::__construct($bookingService, $logger);
}
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
{
return $this->tokenValid;
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
return new Response();
}
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);
}
}
@@ -8,6 +8,7 @@ use App\Controller\Admin\AccommodationBooking\EditController;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Entity\User; use App\Entity\User;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AdditionalServiceRepository; use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository; use App\Repository\Groups\BoardServiceRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
@@ -46,6 +47,39 @@ class EditControllerTest extends TestCase
self::assertSame($status, $booking->getStatus(), 'editing leaves the status where it was'); self::assertSame($status, $booking->getStatus(), 'editing leaves the status where it was');
} }
public function testAnImplausiblePriceComesBackAsAFormErrorWithoutSaving(): void
{
// The office can fix this — in the house's price rows — so it belongs on the form rather
// than on an error page, and nothing may be flushed in the meantime.
$bookingService = $this->createStub(AccommodationBookingService::class);
$bookingService->method('refreshPriceSnapshot')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$form = $this->createMock(FormInterface::class);
$form->method('handleRequest')->willReturn($form);
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn(true);
$form->method('has')->willReturn(false);
$form->expects(self::once())->method('addError');
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$controller = new TestableEditController(
$entityManager,
$this->createStub(LoggerInterface::class),
$this->createStub(BoardServiceRepository::class),
$this->createStub(AdditionalServiceRepository::class),
$this->createStub(UserRepository::class),
$bookingService,
$form,
);
$response = $controller->index(new AccommodationBooking(), Request::create('/admin/accommodation-booking/1/edit', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), 'the form is re-rendered, not redirected');
}
/** /**
* @return iterable<string, array{AccommodationBookingStatus}> * @return iterable<string, array{AccommodationBookingStatus}>
*/ */
@@ -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::HOUSE_MANAGER => 'Hausleitung', 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']);
@@ -36,7 +36,7 @@ class ShowControllerTest extends TestCase
$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']);
} }
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ExportController;
use App\Model\TravelExport;
use App\Model\TravelExportQuery;
use App\Service\TravelExportBuilder;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Response;
class ExportControllerTest extends TestCase
{
public function testPaginatesAndReportsWholeExportFigures(): void
{
$meta = $this->call(new TravelExportQuery(1, 5))['meta'];
self::assertSame(1, $meta['page']);
self::assertSame(5, $meta['perPage']);
self::assertSame(12, $meta['total'], 'total is the whole export, not this page');
self::assertSame(3, $meta['totalPages']);
}
public function testCountersAreIdenticalOnEveryPage(): void
{
// Whole-export figures repeated per page, so a consumer can abort after page 1.
$first = $this->call(new TravelExportQuery(1, 5))['meta'];
$last = $this->call(new TravelExportQuery(3, 5))['meta'];
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'] as $key) {
self::assertSame($first[$key], $last[$key], $key.' must not vary by page');
}
}
public function testConsecutivePagesDoNotOverlap(): void
{
$first = array_column($this->call(new TravelExportQuery(1, 5))['data'], 'busProId');
$second = array_column($this->call(new TravelExportQuery(2, 5))['data'], 'busProId');
self::assertSame([1, 2, 3, 4, 5], $first);
self::assertSame([6, 7, 8, 9, 10], $second);
self::assertSame([], array_intersect($first, $second));
}
public function testTheLastPageIsShort(): void
{
$data = $this->call(new TravelExportQuery(3, 5))['data'];
self::assertCount(2, $data);
self::assertSame([11, 12], array_column($data, 'busProId'));
}
public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void
{
$body = $this->call(new TravelExportQuery(99, 5));
self::assertSame([], $body['data']);
self::assertSame(12, $body['meta']['total']);
}
public function testDefaultsToTheFirstPageOfFiveHundred(): void
{
$body = $this->call();
self::assertSame(1, $body['meta']['page']);
self::assertSame(500, $body['meta']['perPage']);
self::assertSame(1, $body['meta']['totalPages']);
self::assertCount(12, $body['data']);
}
public function testCarriesTheBuilderCounters(): void
{
$meta = $this->call(null, 7, 21)['meta'];
self::assertSame(7, $meta['skipped']);
self::assertSame(21, $meta['pickupsWithoutMasterData']);
self::assertSame('2026-09-29T03:20:00+02:00', $meta['generatedAt']);
}
/**
* A builder failure must reach the consumer as a 5xx, never as the 200-with-an-error-body
* shape TravelController::hotelAvailability() uses: the retry logic keys on the status code.
*/
public function testABuilderFailureIsNotDowngradedToATwoHundred(): void
{
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willThrowException(new \RuntimeException('cache exploded'));
$this->expectException(\RuntimeException::class);
$this->controller($builder)->travels(new TravelExportQuery());
}
/**
* @return array<string, mixed>
*/
private function call(?TravelExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
{
$rows = [];
for ($i = 1; $i <= 12; ++$i) {
$rows[] = ['busProId' => $i, 'hotel' => ['busProId' => 1], 'pickups' => []];
}
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willReturn(new TravelExport(
$rows,
$skipped,
$withoutMasterData,
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
));
$response = $this->controller($builder)->travels($query ?? new TravelExportQuery());
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
return json_decode((string) $response->getContent(), true);
}
private function controller(TravelExportBuilder $builder): ExportController
{
$controller = new ExportController($builder);
// No 'serializer' service registered, so AbstractController::json() falls back to
// JsonResponse -- which is what this endpoint produces in production anyway.
$controller->setContainer(new Container());
return $controller;
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ExportController;
use App\Controller\Api\TravelController;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Attribute\Route as RouteAttribute;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Guards the /api/travels namespace against a sibling route being swallowed by byCode().
*
* `/api/travels/{dateCode}` accepts an arbitrary string, so anything else placed under
* `/api/travels/` competes with every date code, and a path falling through to the wrong
* route answers 404 with a body that reads like an endpoint holding no data. The bulk
* export lives at `/api/exports/travels` for exactly this reason. The collection is built
* by reflecting over the real attributes, so this breaks if the declarations change.
*/
class TravelControllerRoutingTest extends TestCase
{
/**
* @return array<string, array{string, string}>
*/
public static function pathProvider(): array
{
return [
'the export lives outside the travels namespace' => ['/api/exports/travels', 'api_exports_travels'],
// Regression: a dateCode requirement of `.+` instead of the default `[^/]+` widens
// the segment across slashes, and this four-segment path is then swallowed by byCode().
'availability is not shadowed by the code lookup' => [
'/api/travels/12794/225444/2026-12-30/availability',
'api_travel_hotel_availability',
],
'numeric ids still reach byId' => ['/api/travels/12794/225444', 'api_travel_single_id'],
'a date code alone reaches byCode' => ['/api/travels/SBW261226', 'api_travel_single_code'],
'a date and hotel code reach byCode' => ['/api/travels/SBW261226/H1', 'api_travel_single_code'],
'the mapping index is untouched' => ['/api/travels', 'api_travel_mapping'],
];
}
#[DataProvider('pathProvider')]
public function testPathResolvesToTheIntendedRoute(string $path, string $expected): void
{
$matcher = new UrlMatcher($this->collection(), new RequestContext('', 'GET'));
self::assertSame($expected, $matcher->match($path)['_route']);
}
public function testDateCodeNeverSpansSlashes(): void
{
$route = $this->collection()->get('api_travel_single_code');
self::assertNotNull($route);
self::assertStringNotContainsString(
'.+',
(string) $route->getRequirement('dateCode'),
'dateCode must keep the single-segment class, or it shadows the routes below it',
);
}
/**
* The export must stay out of `/api/travels/`, where it would be one date code away from
* ambiguity and would need a negative lookahead on byCode() to be reachable at all.
*/
public function testNothingButTravelLookupsLivesUnderTheTravelsNamespace(): void
{
foreach ($this->collection()->all() as $name => $route) {
self::assertStringNotContainsString(
'/api/travels/export',
$route->getPath(),
$name.' must not sit in the date code namespace',
);
}
}
/**
* Reflects the controller's own #[Route] attributes, in declaration order.
*/
private function collection(): RouteCollection
{
$collection = new RouteCollection();
foreach ([TravelController::class, ExportController::class] as $controller) {
$this->addRoutes($collection, $controller);
}
return $collection;
}
/**
* @param class-string $controller
*/
private function addRoutes(RouteCollection $collection, string $controller): void
{
$class = new \ReflectionClass($controller);
$prefix = '';
foreach ($class->getAttributes(RouteAttribute::class) as $attribute) {
$prefix = (string) $attribute->newInstance()->path;
}
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
foreach ($method->getAttributes(RouteAttribute::class) as $attribute) {
$route = $attribute->newInstance();
$collection->add((string) $route->name, new Route(
$prefix.$route->path,
$route->defaults,
$route->requirements,
[],
'',
[],
$route->methods,
));
}
}
}
}
@@ -27,13 +27,14 @@ class UserinfoControllerTest extends TestCase
{ {
private const LOGIN_EMAIL = '[email protected]'; private const LOGIN_EMAIL = '[email protected]';
private const CONTACT_EMAIL = '[email protected]'; private const CONTACT_EMAIL = '[email protected]';
private const UUID = '018f3c2a-7b4d-4e91-a3c5-6d2f8b1e4a07';
public function testSubAndEmailAreExportedWithoutAnyOptionalScope(): void public function testSubAndEmailAreExportedWithoutAnyOptionalScope(): void
{ {
$claims = $this->claims(); $claims = $this->claims();
self::assertSame(['sub', 'email'], array_keys($claims)); self::assertSame(['sub', 'email'], array_keys($claims));
self::assertSame('42', $claims['sub']); self::assertSame(self::UUID, $claims['sub']);
self::assertSame(self::LOGIN_EMAIL, $claims['email']); self::assertSame(self::LOGIN_EMAIL, $claims['email']);
} }
@@ -43,11 +44,27 @@ class UserinfoControllerTest extends TestCase
// The ids identify the human, the subject identifies the account. A second account of the // 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. // same person reports these same two ids and must still be told apart.
self::assertSame('42', $claims['sub']); self::assertSame(self::UUID, $claims['sub']);
self::assertSame(7, $claims['person_id']); self::assertSame(7, $claims['person_id']);
self::assertSame(9, $claims['address_id']); self::assertSame(9, $claims['address_id']);
} }
/**
* Consumers store `sub` and match their own accounts on it, so it must not disclose how many
* accounts exist or let one account's identifier be guessed from the next. The primary key
* did both.
*/
public function testSubIsTheAccountUuidRatherThanItsPrimaryKey(): void
{
$claims = $this->claims();
self::assertNotSame('42', $claims['sub']);
self::assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
$claims['sub']
);
}
public function testEmailIsTheAuthenticatedAddressAndNotTheFirstContactOnTheBusProRecord(): void public function testEmailIsTheAuthenticatedAddressAndNotTheFirstContactOnTheBusProRecord(): void
{ {
$claims = $this->claims(['ROLE_OAUTH2_PROFILE']); $claims = $this->claims(['ROLE_OAUTH2_PROFILE']);
@@ -70,6 +87,21 @@ class UserinfoControllerTest extends TestCase
self::assertSame([Role::EMPLOYEE, Role::TEAMER], $claims['roles']); self::assertSame([Role::EMPLOYEE, Role::TEAMER], $claims['roles']);
} }
/**
* myep-team reads its own administrative role out of this claim and revokes what the claim
* omits, so the export is asserted here rather than left to the role policy's own tests.
*/
public function testTeamAdminIsExportedOnceApproved(): void
{
$claims = $this->claims(['ROLE_OAUTH2_ROLES'], [
Role::USER,
Role::EMPLOYEE,
Role::TEAM_ADMIN,
]);
self::assertSame([Role::EMPLOYEE, Role::TEAM_ADMIN], $claims['roles']);
}
public function testUpstreamFailureIsReportedRatherThanAnswered(): void public function testUpstreamFailureIsReportedRatherThanAnswered(): void
{ {
$apiClient = $this->createStub(ApiClient::class); $apiClient = $this->createStub(ApiClient::class);
@@ -145,10 +177,15 @@ class UserinfoControllerTest extends TestCase
->setRoles($roles) ->setRoles($roles)
; ;
// The id is generated by Doctrine and has no setter, but it is what `sub` exports. // The id is generated by Doctrine and has no setter. It is deliberately not what `sub`
// exports -- see testSubIsTheAccountUuidRatherThanItsPrimaryKey.
$property = new \ReflectionProperty(User::class, 'id'); $property = new \ReflectionProperty(User::class, 'id');
$property->setValue($user, 42); $property->setValue($user, 42);
// The constructor generates a fresh uuid; pin it so the claim can be asserted literally.
$property = new \ReflectionProperty(User::class, 'uuid');
$property->setValue($user, self::UUID);
return $user; return $user;
} }
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Booking\Create;
use App\BusProNet\Constants;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\Controller\Booking\Create\Step2Controller;
use App\Form\Model\BookingDto;
use App\Form\Model\RoomSelectionDto;
use App\Model\RoomSelectionAdjustment;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
use App\Service\ParticipantDataPrefiller;
use App\Service\RoomAssigner;
use App\Service\TravelDataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\User\UserInterface;
class Step2ControllerTest extends TestCase
{
public function testSendsCustomerBackToStep1WhenASelectedRoomIsNoLongerBookable(): void
{
$request = Request::create('/bookings/create/participants', 'GET');
$travel = $this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 1)]);
$bookingDto = $this->createBookingDto($travel);
$bookingService = $this->createMock(BookingConfigurator::class);
$bookingService->expects($this->once())
->method('reconcileRoomSelections')
->with($bookingDto)
->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 2, 0)]);
$bookingService->expects($this->never())->method('preselectDefaultServices');
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->once())
->method('saveBookingDto')
->with($request, $bookingDto, BookingDto::MODE_CREATE);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())
->method('error')
->with(
'Room selection exceeds what the current travel data still offers.',
[
'travelId' => 4711,
'travelCode' => 'TRV-4711',
'hotelId' => 1,
'rooms' => [[
'roomId' => 1408,
'label' => 'Doppelzimmer',
'requestedQuantity' => 2,
'bookableQuantity' => 0,
]],
]
);
$controller = $this->createController($bookingService, $bookingSessionService, $logger);
$response = $controller->index($request);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/app_booking_create_step_1', $response->getTargetUrl());
$this->assertSame(
[['error', 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.']],
$controller->flashes
);
}
public function testSendsCustomerBackToStep1WithTheCappedQuantityPrefilled(): void
{
$request = Request::create('/bookings/create/participants', 'GET');
$travel = $this->createTravel([$this->createRoom(1408, 'Doppelzimmer', minPax: 2)]);
$bookingDto = $this->createBookingDto($travel);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, 'Doppelzimmer', quantity: 1, capacity: 2),
];
$bookingService = $this->createMock(BookingConfigurator::class);
$bookingService->expects($this->once())
->method('reconcileRoomSelections')
->with($bookingDto)
->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 3, 1)]);
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->once())
->method('saveBookingDto')
->with($request, $bookingDto, BookingDto::MODE_CREATE);
$controller = $this->createController(
$bookingService,
$bookingSessionService,
$this->createStub(LoggerInterface::class)
);
$response = $controller->index($request);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/app_booking_create_step_1', $response->getTargetUrl());
$this->assertSame(
[['error', 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.']],
$controller->flashes
);
$this->assertSame(1, $bookingDto->roomSelections[1408]->quantity, 'Step 1 reopens on the capped selection');
}
public function testSendsCustomerToTheErrorPageWhenTheTravelSoldOutCompletely(): void
{
$request = Request::create('/bookings/create/participants', 'GET');
$bookingDto = $this->createBookingDto($this->createTravel([]));
$bookingService = $this->createMock(BookingConfigurator::class);
$bookingService->expects($this->once())
->method('reconcileRoomSelections')
->with($bookingDto)
->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 2, 0)]);
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->once())
->method('saveBookingDto')
->with($request, $bookingDto, BookingDto::MODE_CREATE);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())
->method('error')
->with('Travel has no bookable rooms left while a booking is in progress.', $this->anything());
$controller = $this->createController($bookingService, $bookingSessionService, $logger);
$response = $controller->index($request);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/app_booking_create_error', $response->getTargetUrl());
$this->assertSame(
[['error', 'Für diese Reise sind leider keine Zimmer mehr verfügbar.']],
$controller->flashes
);
}
public function testContinuesWhenOnlyUnselectedRoomsWereDropped(): void
{
$request = Request::create('/bookings/create/participants', 'GET');
$travel = $this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 3)]);
$bookingDto = $this->createBookingDto($travel);
$bookingDto->roomSelections = [
1409 => $this->createRoomSelection(1409, 'Einzelzimmer', quantity: 2, capacity: 3),
];
$bookingService = $this->createMock(BookingConfigurator::class);
$bookingService->expects($this->once())
->method('reconcileRoomSelections')
->with($bookingDto)
->willReturn([]);
$bookingSessionService = $this->createMock(BookingSessionManager::class);
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
$bookingSessionService->expects($this->exactly(2))
->method('saveBookingDto')
->with($request, $bookingDto, BookingDto::MODE_CREATE);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->never())->method('error');
$controller = $this->createController($bookingService, $bookingSessionService, $logger);
$response = $controller->index($request);
$this->assertSame([], $controller->flashes);
$this->assertInstanceOf(Response::class, $response);
$this->assertCount(6, $bookingDto->participants, 'Two rooms for three people each yield six participants');
}
private function createController(
BookingConfigurator $bookingService,
BookingSessionManager $bookingSessionService,
LoggerInterface $logger,
): TestableStep2Controller {
$form = $this->createStub(FormInterface::class);
$form->method('isSubmitted')->willReturn(false);
return new TestableStep2Controller(
$bookingService,
$bookingSessionService,
$this->createStub(BookingCreateContextFactory::class),
$this->createStub(TravelDataProvider::class),
$this->createStub(RoomAssigner::class),
$this->createStub(ParticipantDataPrefiller::class),
$logger,
$form,
);
}
private function createBookingDto(Travel $travel): BookingDto
{
$bookingDto = new BookingDto($travel, 1);
$bookingDto->currentStep = 2;
return $bookingDto;
}
/**
* @param array<int, Room> $rooms
*/
private function createTravel(array $rooms): Travel
{
$travel = new Travel();
$travel->id = 4711;
$travel->code = 'TRV-4711';
$travel->rooms = $rooms;
return $travel;
}
private function createRoom(int $id, string $label, int $minPax): Room
{
$room = new Room();
$room->id = $id;
$room->label = $label;
$room->available = 4;
$room->minPax = $minPax;
$room->status = Constants::STATUS_AVAILABLE;
return $room;
}
private function createRoomSelection(int $id, string $label, int $quantity, int $capacity): RoomSelectionDto
{
$selection = new RoomSelectionDto();
$selection->id = $id;
$selection->label = $label;
$selection->quantity = $quantity;
$selection->capacity = $capacity;
return $selection;
}
}
final class TestableStep2Controller extends Step2Controller
{
/**
* @var array<int, array{string, mixed}>
*/
public array $flashes = [];
/**
* @var array<string, mixed>
*/
public array $renderParameters = [];
/**
* @var FormInterface<mixed>
*/
private readonly FormInterface $form;
/**
* @param FormInterface<mixed> $form
*/
public function __construct(
BookingConfigurator $bookingService,
BookingSessionManager $bookingSessionService,
BookingCreateContextFactory $createContextFactory,
TravelDataProvider $travelDataService,
RoomAssigner $roomAssignmentService,
ParticipantDataPrefiller $prepopulationService,
LoggerInterface $logger,
FormInterface $form,
) {
$this->form = $form;
parent::__construct(
$bookingService,
$bookingSessionService,
$createContextFactory,
$travelDataService,
$roomAssignmentService,
$prepopulationService,
$logger,
);
}
protected function getUser(): ?UserInterface
{
return null;
}
/**
* @return FormInterface<mixed>
*/
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
return $this->form;
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = [$type, $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
{
return new RedirectResponse('/'.$route, $status);
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderParameters = $parameters;
return new Response('');
}
}
@@ -8,11 +8,13 @@ use App\Controller\Groups\Booking\Step4Controller;
use App\Entity\Groups\Accommodation; use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingOrigin;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager; use App\Service\AccommodationSessionManager;
use App\Service\AccommodationTermsUrlProvider; use App\Service\AccommodationTermsUrlProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Form\FormInterface; 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;
@@ -302,6 +304,96 @@ class Step4ControllerTest extends TestCase
self::assertNull($sessionManager->getDto($request)); self::assertNull($sessionManager->getDto($request));
} }
public function testConfirmPostEndsOnTheErrorPageWhenThePriceIsImplausible(): void
{
// A mistyped catalog price is the one way an implausible total still gets computed, and
// the customer must not meet it as a 500 — SQLSTATE 22003 on /admin is what started this.
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4/confirm', 'POST');
$request->setSession($session);
$request->headers->set('HX-Request', 'true');
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$bookingService->method('loadPrices')->willReturn([]);
$bookingService->method('finalizeBooking')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm);
$response = $controller->confirm($request);
self::assertSame('/app_groups_booking_error', $response->headers->get('HX-Redirect'));
// The session survives: the desk still has the inquiry to pick up by phone.
self::assertNotNull($sessionManager->getDto($request));
}
public function testIndexPostEndsOnTheErrorPageWhenThePriceIsImplausible(): void
{
$dto = new AccommodationBookingDto();
$dto->currentStep = 4;
$dto->accommodationId = 1;
$session = new Session(new MockArraySessionStorage());
$request = Request::create('/groups/booking/step-4', 'POST');
$request->setSession($session);
$sessionManager = new AccommodationSessionManager();
$sessionManager->save($request, $dto);
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation);
$bookingService->method('loadAvailableServices')->willReturn([
'boardServices' => [],
'additionalServices' => [],
'groupedAdditionalServices' => [],
'ungroupedAdditionalServices' => [],
]);
$bookingService->method('loadPrices')->willReturn([]);
$bookingService->method('finalizeBooking')
->willThrowException(new ImplausibleBookingPriceException(AccommodationBooking::MAX_TOTAL_PRICE + 1, 40, 2));
$inquiryForm = $this->createStub(FormInterface::class);
$inquiryForm->method('handleRequest')->willReturnSelf();
$inquiryForm->method('isSubmitted')->willReturn(true);
$inquiryForm->method('isValid')->willReturn(true);
$controller = new TestableStep4Controller($bookingService, $sessionManager, $inquiryForm);
$response = $controller->index($request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_booking_error', $response->getTargetUrl());
}
public function testConfirmPostInvalidReRendersModalWithoutPersisting(): void public function testConfirmPostInvalidReRendersModalWithoutPersisting(): void
{ {
$dto = new AccommodationBookingDto(); $dto = new AccommodationBookingDto();
@@ -358,6 +450,7 @@ final class TestableStep4Controller extends Step4Controller
$bookingService, $bookingService,
$sessionManager, $sessionManager,
new AccommodationTermsUrlProvider(['AT' => '', 'CH' => '', 'IT' => ''], 'https://example.test/agb/'), new AccommodationTermsUrlProvider(['AT' => '', 'CH' => '', 'IT' => ''], 'https://example.test/agb/'),
new NullLogger(),
); );
} }
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\BusProNet\Model\Travel;
use App\Controller\SecurityController;
use App\Form\Model\BookingDto;
use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\Client;
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;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityControllerTest extends TestCase
{
private const AUTHORIZE_PATH = '/authorize?response_type=code&client_id=abc123&redirect_uri=https%3A%2F%2Fephub.example%2Fcallback';
public function testAnAuthorizeTargetPathRendersTheSelfExplainingLoginPageNamingTheClient(): void
{
$controller = $this->createController();
$controller->clientManager
->expects(self::once())
->method('find')
->with('abc123')
->willReturn(new Client('EPHub', 'abc123', null));
$response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
self::assertTrue($controller->renderedParameters['oauth2']);
self::assertSame('EPHub', $controller->renderedParameters['client_name']);
}
public function testWithoutAnAuthorizeTargetPathTheStandardLoginPageIsRendered(): void
{
$controller = $this->createController();
$controller->clientManager->expects(self::never())->method('find');
$response = $controller->login(...$this->arguments($controller, null));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login.html.twig', $controller->renderedView);
self::assertFalse($controller->renderedParameters['oauth2']);
self::assertNull($controller->renderedParameters['client_name']);
}
/**
* The name is cosmetic and this runs before authentication, so an unknown client must degrade
* to neutral wording rather than break the login.
*/
public function testAnUnresolvableClientIdFallsBackToNeutralWording(): void
{
$controller = $this->createController();
$controller->clientManager
->expects(self::once())
->method('find')
->willReturn(null);
$response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
self::assertNull($controller->renderedParameters['client_name']);
}
public function testAnAuthorizeTargetPathWithoutAClientIdFallsBackToNeutralWording(): void
{
$controller = $this->createController();
$controller->clientManager->expects(self::never())->method('find');
$response = $controller->login(...$this->arguments($controller, '/authorize'));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
self::assertNull($controller->renderedParameters['client_name']);
}
/**
* A booking left half-finished in the same browser used to overwrite the pending target path
* with booking step 1, so the application that asked for the authorization never got its code.
*/
public function testAPendingAuthorizationWinsOverAHalfFinishedBooking(): void
{
$controller = $this->createController(bookingDto: $this->createBookingDto());
$controller->clientManager
->expects(self::once())
->method('find')
->willReturn(new Client('EPHub', 'abc123', null));
$session = $this->createSession(self::AUTHORIZE_PATH);
$response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH, $session));
self::assertSame(200, $response->getStatusCode());
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
self::assertSame(self::AUTHORIZE_PATH, $session->get('_security.main.target_path'));
}
public function testAHalfFinishedBookingStillWinsWhenThereIsNoAuthorization(): void
{
$controller = $this->createController(bookingDto: $this->createBookingDto());
$controller->clientManager->expects(self::never())->method('find');
$session = $this->createSession(null);
$response = $controller->login(...$this->arguments($controller, null, $session));
self::assertSame(200, $response->getStatusCode());
self::assertSame('booking/create/authenticate.html.twig', $controller->renderedView);
self::assertSame('/booking/create/step-1', $session->get('_security.main.target_path'));
}
/**
* @return array{AuthenticationUtils, Request, BookingSessionManager, BookingSummaryAssembler, ClientManagerInterface}
*/
private function arguments(TestableSecurityController $controller, ?string $targetPath, ?Session $session = null): array
{
$request = Request::create('/', 'GET');
$request->setSession($session ?? $this->createSession($targetPath));
return [
$controller->authenticationUtils,
$request,
$controller->bookingSessionManager,
$controller->summaryAssembler,
$controller->clientManager,
];
}
private function createSession(?string $targetPath): Session
{
$session = new Session(new MockArraySessionStorage());
if (null !== $targetPath) {
$session->set('_security.main.target_path', $targetPath);
}
return $session;
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->id = 123;
return new BookingDto($travel, 77);
}
private function createController(?BookingDto $bookingDto = null): TestableSecurityController
{
$authenticationUtils = $this->createStub(AuthenticationUtils::class);
$authenticationUtils->method('getLastAuthenticationError')->willReturn(null);
$authenticationUtils->method('getLastUsername')->willReturn('');
$bookingSessionManager = $this->createStub(BookingSessionManager::class);
$bookingSessionManager->method('getBookingDto')->willReturn($bookingDto);
return new TestableSecurityController(
$authenticationUtils,
$bookingSessionManager,
$this->createStub(BookingSummaryAssembler::class),
$this->createMock(ClientManagerInterface::class),
);
}
}
final class TestableSecurityController extends SecurityController
{
/**
* @var array<string, mixed>
*/
public array $renderedParameters = [];
public string $renderedView = '';
public function __construct(
public readonly AuthenticationUtils $authenticationUtils,
public readonly BookingSessionManager $bookingSessionManager,
public readonly BookingSummaryAssembler $summaryAssembler,
public readonly ClientManagerInterface $clientManager,
) {
}
protected function getUser(): ?UserInterface
{
return null;
}
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return new Response('ok');
}
protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
return '/booking/create/step-1';
}
}
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Tests\Entity\Groups; namespace App\Tests\Entity\Groups;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -87,6 +89,36 @@ class AccommodationBookingTest extends TestCase
} }
} }
public function testAnImplausibleTotalIsRefusedRatherThanStored(): void
{
// The pax count and the nights are capped, so what gets here is a catalog price with a
// digit too many. A stored total would end up on an offer; the INT column would answer
// it with SQLSTATE 22003 and say nothing about the cause.
$booking = new AccommodationBooking();
$booking->setDateFrom(new \DateTimeImmutable('2026-09-23'));
$booking->setDateTo(new \DateTimeImmutable('2026-09-25'));
$booking->setPaxCount(40);
$this->expectException(ImplausibleBookingPriceException::class);
$booking->setPriceSnapshot(['total' => AccommodationBooking::MAX_TOTAL_PRICE + 1], AccommodationBooking::MAX_TOTAL_PRICE + 1, 'EUR', 1);
}
public function testATotalAtTheCeilingIsStillStored(): void
{
$booking = new AccommodationBooking();
$booking->setPriceSnapshot(['total' => AccommodationBooking::MAX_TOTAL_PRICE], AccommodationBooking::MAX_TOTAL_PRICE, 'EUR', 1);
self::assertSame(AccommodationBooking::MAX_TOTAL_PRICE, $booking->getTotalPrice());
}
public function testTheCeilingLeavesTheIntColumnRoomToSpare(): void
{
// The ceiling exists to keep implausible prices out, not to use up the column: a total
// that needs more room than this is a mistake, never a wider column.
self::assertLessThan(2147483647, AccommodationBooking::MAX_TOTAL_PRICE);
}
public function testTheLabelIsIndependentOfWhereTheBookingCameFrom(): void public function testTheLabelIsIndependentOfWhereTheBookingCameFrom(): void
{ {
// Origin answers "how did this come about" and is deliberately not the same // Origin answers "how did this come about" and is deliberately not the same
@@ -100,4 +132,117 @@ class AccommodationBookingTest extends TestCase
self::assertSame('Buchung', $booking->recordLabel(), $origin->value); self::assertSame('Buchung', $booking->recordLabel(), $origin->value);
} }
} }
public function testACopyGetsItsOwnIdentityAndAccessLinkToken(): void
{
// Two records behind one uuid would mean two records behind one customer access link.
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertNull($copy->getId());
self::assertNotSame($booking->getUuid(), $copy->getUuid());
}
public function testACopyStartsAsAFreshOfficeDraftThatNobodyHasSeen(): void
{
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertSame(AccommodationBookingStatus::Draft, $copy->getStatus());
self::assertSame(AccommodationBookingOrigin::Offer, $copy->getOrigin());
self::assertNull($copy->getAccessLinkIssuedAt());
self::assertNull($copy->getAcceptedAt());
self::assertNull($copy->getConfirmedAt());
self::assertNull($copy->getUpdatedAt());
self::assertNull($copy->getCreatedBy());
self::assertNull($copy->getUpdatedBy());
self::assertSame('Angebot', $copy->recordLabel());
}
public function testACopyCarriesThePricingAndTheDiscountsVerbatim(): void
{
// The copy is a variation of an offer that was already quoted, so it shows the same
// numbers rather than a re-quote at today's catalog prices.
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertSame($booking->getPriceBreakdown(), $copy->getPriceBreakdown());
self::assertSame($booking->getTotalPrice(), $copy->getTotalPrice());
self::assertSame($booking->getPricingCurrency(), $copy->getPricingCurrency());
self::assertSame($booking->getPricingVersion(), $copy->getPricingVersion());
self::assertSame(10, $copy->getAccommodationDiscount());
self::assertSame(5, $copy->getBoardServiceDiscount());
self::assertSame(20, $copy->getAdditionalServicesDiscount());
self::assertSame(2500, $copy->getTotalDiscountAmount());
self::assertSame('Treuerabatt', $copy->getTotalDiscountLabel());
}
public function testACopyKeepsWhatTheOfficeWouldOtherwiseKeyInAgain(): void
{
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertSame($booking->getDateFrom(), $copy->getDateFrom());
self::assertSame($booking->getDateTo(), $copy->getDateTo());
self::assertSame(42, $copy->getPaxCount());
self::assertSame('Vollpension', $copy->getBoardServiceLabel());
self::assertSame(1900, $copy->getBoardServicePrice());
self::assertSame(7, $copy->getBoardServiceOriginalId());
self::assertSame($booking->getAdditionalServices(), $copy->getAdditionalServices());
self::assertSame('Skiclub Nord', $copy->getGroupName());
self::assertSame('[email protected]', $copy->getEmail());
}
public function testCloningLeavesTheOriginalAlone(): void
{
$booking = $this->confirmedBooking();
clone $booking;
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
self::assertNotNull($booking->getAcceptedAt());
self::assertNotNull($booking->getConfirmedAt());
self::assertSame('Skiclub Nord', $booking->getGroupName());
}
/**
* A record that has been all the way through the lifecycle, so a copy of it has the most
* to reset.
*/
private function confirmedBooking(): AccommodationBooking
{
$now = new \DateTimeImmutable('2026-01-15 10:00:00');
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setOrigin(AccommodationBookingOrigin::Direct);
$booking->setAccessLinkIssuedAt($now);
$booking->setAcceptedAt($now);
$booking->setConfirmedAt($now);
$booking->setCreatedAt($now);
$booking->setUpdatedAt($now);
$booking->setCreatedBy(new User('[email protected]'));
$booking->setUpdatedBy(new User('[email protected]'));
$booking->setDateFrom(new \DateTimeImmutable('2026-03-01'));
$booking->setDateTo(new \DateTimeImmutable('2026-03-08'));
$booking->setPaxCount(42);
$booking->setGroupName('Skiclub Nord');
$booking->setEmail('[email protected]');
$booking->setBoardServiceLabel('Vollpension');
$booking->setBoardServicePrice(1900);
$booking->setBoardServiceOriginalId(7);
$booking->addAdditionalServiceSnapshot('Skipass', 12000, 'per_person', 3);
$booking->setAccommodationDiscount(10);
$booking->setBoardServiceDiscount(5);
$booking->setAdditionalServicesDiscount(20);
$booking->setTotalDiscountAmount(2500);
$booking->setTotalDiscountLabel('Treuerabatt');
$booking->setPriceSnapshot(['total' => 500000], 480000, 'EUR', 1);
return $booking;
}
} }
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class UserTest extends TestCase
{
/**
* The uuid is what /api/userinfo exports as `sub`, and consumers key their own accounts on
* it, so an account has to carry one from the moment it exists -- there is no later step
* that could assign it.
*/
public function testAnAccountIsGivenAUuidOnCreation(): void
{
$user = new User('[email protected]');
self::assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
$user->getUuid()
);
}
/**
* v4, not the framework's default v7: a v7 would sort by creation time and carry that
* timestamp in the claim, which is the disclosure the column exists to avoid.
*/
public function testUuidsAreRandomRatherThanOrdered(): void
{
$uuids = [];
for ($i = 0; $i < 50; ++$i) {
$uuids[] = (new User("someone{$i}@ep-reisen.de"))->getUuid();
}
self::assertCount(50, array_unique($uuids));
$sorted = $uuids;
sort($sorted);
self::assertNotSame($sorted, $uuids, 'v4 uuids must not come out in creation order');
}
}
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Entity\OAuth2Client;
use App\Entity\User;
use App\EventListener\AuthorizationCodeListener;
use App\Security\Role;
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
use League\Bundle\OAuth2ServerBundle\Model\ClientInterface;
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Twig\Environment;
class AuthorizationCodeListenerTest extends TestCase
{
public function testAnonymousRequestIsRedirectedToLoginWithoutResolving(): void
{
$event = $this->dispatch(null, $this->client(['ROLE_EMPLOYEE']));
$response = $event->getResponse();
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame(Response::HTTP_TEMPORARY_REDIRECT, $response->getStatusCode());
self::assertFalse($event->getAuthorizationResolution());
}
public function testAccountHoldingOneOfTheRequiredRolesIsApproved(): void
{
$event = $this->dispatch(
$this->user([Role::EMPLOYEE]),
$this->client([Role::EMPLOYEE, Role::ADMIN])
);
self::assertTrue($event->getAuthorizationResolution());
self::assertNull($event->getResponse());
}
public function testAccountHoldingNoneOfTheRequiredRolesIsDenied(): void
{
$event = $this->dispatch(
$this->user([Role::CUSTOMER]),
$this->client([Role::EMPLOYEE, Role::ADMIN])
);
self::assertFalse($event->getAuthorizationResolution());
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
}
/**
* A CRM nomination grants nothing until an administrator approves it, so it must not
* open a client either.
*/
public function testPendingMarkerDoesNotSatisfyTheRequirement(): void
{
$event = $this->dispatch(
$this->user([Role::ADMIN.Role::PENDING_SUFFIX]),
$this->client([Role::ADMIN])
);
self::assertFalse($event->getAuthorizationResolution());
}
/**
* The column starts empty, so "nobody configured this client" and "this client needs
* no roles" are the same value. Only one of the two readings is safe.
*/
public function testClientWithoutConfiguredRolesIsDenied(): void
{
$event = $this->dispatch($this->user([Role::ADMIN]), $this->client([]));
self::assertFalse($event->getAuthorizationResolution());
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
}
public function testClientOfAnotherImplementationIsDenied(): void
{
$foreign = $this->createStub(ClientInterface::class);
$foreign->method('getIdentifier')->willReturn('f3f10f8807e191c127d74cc9a247bf45');
$event = $this->dispatch($this->user([Role::ADMIN]), $foreign);
self::assertFalse($event->getAuthorizationResolution());
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
}
public function testExternalRequestLogsTheUserOutAfterApproval(): void
{
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($this->user([Role::EMPLOYEE]));
$security->expects(self::once())->method('logout')->with(false);
$this->dispatch(
$this->user([Role::EMPLOYEE]),
$this->client([Role::EMPLOYEE]),
external: true,
security: $security
);
}
/**
* A denied user has to stay signed in: they need a session to read the explanation and
* navigate away from it.
*/
public function testExternalRequestDoesNotLogTheUserOutAfterDenial(): void
{
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($this->user([Role::CUSTOMER]));
$security->expects(self::never())->method('logout');
$this->dispatch(
$this->user([Role::CUSTOMER]),
$this->client([Role::EMPLOYEE]),
external: true,
security: $security
);
}
/**
* @param list<string> $requiredRoles
*/
private function client(array $requiredRoles): OAuth2Client
{
return (new OAuth2Client('EPHub', 'f3f10f8807e191c127d74cc9a247bf45', 'secret'))
->setRequiredRoles($requiredRoles);
}
/**
* @param list<string> $roles
*/
private function user(array $roles): User
{
return (new User('[email protected]'))->setRoles($roles);
}
private function dispatch(
?UserInterface $user,
ClientInterface $client,
bool $external = false,
?Security $security = null,
): AuthorizationRequestResolveEvent {
$request = new Request();
$session = new Session(new MockArraySessionStorage());
$session->set('_oauth2', $external);
$request->setSession($session);
$requestStack = new RequestStack();
$requestStack->push($request);
if (null === $security) {
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($user);
}
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
$urlGenerator->method('generate')->willReturn('/');
$twig = $this->createStub(Environment::class);
$twig->method('render')->willReturn('<html lang="de"></html>');
$listener = new AuthorizationCodeListener(
$urlGenerator,
$requestStack,
$security,
$this->createStub(LoggerInterface::class),
$twig,
);
$event = new AuthorizationRequestResolveEvent(
$this->createStub(AuthorizationRequestInterface::class),
[],
$client,
$user ?? new User('[email protected]'),
);
$listener->onAuthorizationRequestResolve($event);
return $event;
}
}
@@ -109,6 +109,60 @@ class AccommodationBookingTypeTest extends TestCase
self::assertCount(0, $this->validate($booking, ['Default'])); self::assertCount(0, $this->validate($booking, ['Default']));
} }
/**
* The overflow this guards against reached production as an out-of-range write on
* total_price: every figure in the breakdown is multiplied by the pax count and the
* nights, so an implausible input for either produces an implausible total.
*/
public function testADepartureBeforeTheArrivalIsRejected(): void
{
$booking = $this->bookingFor('2026-09-23', '2026-09-20', 30);
$violations = $this->validate($booking, ['Default']);
self::assertCount(1, $violations);
self::assertSame('dateTo', $violations[0]->getPropertyPath());
}
public function testAMistypedYearIsRejectedRatherThanPriced(): void
{
// 2226 instead of 2026 — one wrong digit, 200 years of running costs.
$booking = $this->bookingFor('2026-09-23', '2226-09-25', 30);
$violations = $this->validate($booking, ['Default']);
self::assertCount(1, $violations);
self::assertSame('dateTo', $violations[0]->getPropertyPath());
}
public function testAnImplausiblePaxCountIsRejected(): void
{
$booking = $this->bookingFor('2026-09-23', '2026-09-25', AccommodationBooking::MAX_PAX + 1);
$violations = $this->validate($booking, ['Default']);
self::assertCount(1, $violations);
self::assertSame('paxCount', $violations[0]->getPropertyPath());
}
public function testTheLargestBookingOnRecordStillPasses(): void
{
// 150 people for 18 nights: the ceilings must not get in the way of real business.
$booking = $this->bookingFor('2026-09-23', '2026-10-11', 150);
self::assertCount(0, $this->validate($booking, ['Default']));
}
private function bookingFor(string $dateFrom, string $dateTo, int $paxCount): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setDateFrom(new \DateTimeImmutable($dateFrom));
$booking->setDateTo(new \DateTimeImmutable($dateTo));
$booking->setPaxCount($paxCount);
return $booking;
}
/** /**
* @return string[] * @return string[]
*/ */
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\BusProNet\Model\Travel;
use App\Form\BookingCreateStep4Type;
use App\Form\Model\BookingDto;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\Forms;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Validator\Validation;
class BookingCreateStep4TypeTest extends TestCase
{
public function testCompanionOptInIsAbsentByDefault(): void
{
$form = $this->createForm();
self::assertFalse($form->has('companionNewsletterOptIn'));
}
public function testCompanionOptInIsAddedWhenTheOptionIsOn(): void
{
$form = $this->createForm(['show_companion_newsletter_opt_in' => true]);
self::assertTrue($form->has('companionNewsletterOptIn'));
self::assertFalse($form->get('companionNewsletterOptIn')->isRequired());
}
public function testCompanionOptInIsIndependentOfTheApplicantOptIn(): void
{
$form = $this->createForm(['show_companion_newsletter_opt_in' => true]);
self::assertFalse($form->has('newsletterOptIn'));
self::assertTrue($form->has('companionNewsletterOptIn'));
}
/**
* @param array<string, mixed> $options
*
* @return FormInterface<mixed>
*/
private function createForm(array $options = []): FormInterface
{
$travel = new Travel();
$travel->id = 123;
return Forms::createFormFactoryBuilder()
->addExtension(new ValidatorExtension(Validation::createValidator()))
->addExtension(new PreloadedExtension([new BookingCreateStep4Type('https://example.com/agb')], []))
->getFormFactory()
->create(BookingCreateStep4Type::class, new BookingDto($travel, 77), $options);
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Model;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Model\AccommodationBookingDto;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validation;
class AccommodationBookingDtoTest extends TestCase
{
/**
* Step 2 is the screen the customer types the head count on, and the one that prices it.
* Bounding it only in step_3 left the figure to surface two steps later, as a booking that
* could not be stored.
*/
#[DataProvider('implausibleHeadCounts')]
public function testAnImplausibleHeadCountIsRejectedWhereItIsEntered(string $property, int $value): void
{
$dto = new AccommodationBookingDto();
$dto->{$property} = $value;
$properties = array_map(
static fn ($violation) => $violation->getPropertyPath(),
iterator_to_array($this->validate($dto, ['step_2'])),
);
self::assertContains($property, $properties);
}
/**
* @return iterable<string, array{string, int}>
*/
public static function implausibleHeadCounts(): iterable
{
yield 'pax above the ceiling' => ['paxCount', AccommodationBooking::MAX_PAX + 1];
yield 'pax below one' => ['paxCount', 0];
yield 'negative pax' => ['paxCount', -5];
yield 'minors above the ceiling' => ['minorsCount', AccommodationBooking::MAX_PAX + 1];
yield 'negative minors' => ['minorsCount', -1];
yield 'children above the ceiling' => ['childrenCount', AccommodationBooking::MAX_PAX + 1];
}
public function testAFullHouseStillPasses(): void
{
$dto = new AccommodationBookingDto();
$dto->paxCount = AccommodationBooking::MAX_PAX;
$dto->minorsCount = 4;
$dto->childrenCount = 60;
$dto->selectedBoardServiceId = 1;
self::assertCount(0, $this->validate($dto, ['step_2']));
}
/**
* @param string[] $groups
*/
private function validate(AccommodationBookingDto $dto, array $groups): ConstraintViolationListInterface
{
return Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
->validate($dto, null, $groups);
}
}
@@ -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);
} }
@@ -10,6 +10,7 @@ use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DirectionMapper; use App\BusProNet\Utility\DirectionMapper;
use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\TravelParser; use App\BusProNet\XmlParser\TravelParser;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
@@ -487,7 +488,7 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase
*/ */
private function createTravelWithExportedSkiPasses(array $serviceIds): Travel private function createTravelWithExportedSkiPasses(array $serviceIds): Travel
{ {
$parser = new TravelParser(); $parser = new TravelParser(new AgeConstraintParserRegistry(), true);
$crawler = XmlCrawlerFactory::create(file_get_contents('/var/www/html/var/xmlexport/Ziel_2456.xml')); $crawler = XmlCrawlerFactory::create(file_get_contents('/var/www/html/var/xmlexport/Ziel_2456.xml'));
$travelNode = $crawler->filterXPath('//reise/termin')->first(); $travelNode = $crawler->filterXPath('//reise/termin')->first();
$travel = $parser->parse($travelNode); $travel = $parser->parse($travelNode);
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise code="SBW">
<!-- Two hotels on one date: the cross product must produce one row per pair. -->
<termin id="1" idbuspro="900" idprodukt="1" termin="26.12.2026" bis="30.12.2026" code="SBW261226" reiseart="F">
<text> Semesterende - Portes du Soleil </text>
<hotel idbuspro="1" code="H1"/>
<hotel idbuspro="2" code="H2"/>
<zustiege>
<zustieg id="1" idbuspro="10" zeit="26.12.2026 23:59:00"/>
<!-- Time only, and earlier than the 23:59 above: rolls onto the next calendar day. -->
<zustieg id="2" idbuspro="11" zeit="02:15"/>
<!-- No zeit at all: the entry survives with both time fields null. -->
<zustieg id="3" idbuspro="12"/>
<!-- Referenced but absent from zustiege.xml. -->
<zustieg id="4" idbuspro="99" zeit="26.12.2026 21:00:00"/>
</zustiege>
</termin>
<!-- Summer date, to pin the +02:00 offset. -->
<termin id="2" idbuspro="901" termin="15.07.2027" bis="22.07.2027" code="SBW270715">
<text>Sommer</text>
<hotel idbuspro="1" code="H1"/>
<zustiege>
<zustieg id="1" idbuspro="10" zeit="15.07.2027 08:00:00"/>
<!-- Time only and after the full datetime above: stays on the departure date. -->
<zustieg id="2" idbuspro="11" zeit="09:30"/>
</zustiege>
</termin>
<!-- Hotel absent from hotel.xml: the row is dropped and counted. -->
<termin id="3" idbuspro="902" termin="01.02.2027" bis="08.02.2027" code="SBW270201">
<text>Ohne Hotel</text>
<hotel idbuspro="404" code="GHOST"/>
</termin>
<!-- Unparseable date: both its hotel rows are dropped and counted. -->
<termin id="4" idbuspro="903" termin="kaputt" bis="08.02.2027" code="SBW270301">
<text>Kaputtes Datum</text>
<hotel idbuspro="1" code="H1"/>
<hotel idbuspro="2" code="H2"/>
</termin>
</reise>
</reisen>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise code="SBW">
<termin id="1" idbuspro="900" termin="26.12.2026" bis="30.12.2026" code="SBW261226">
<text>Zweiter Treffer</text>
<hotel idbuspro="1" code="H1"/>
</termin>
</reise>
</reisen>
+38 -1
View File
@@ -76,6 +76,35 @@ class BpnAuthenticatorTest extends TestCase
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::HOUSE_MANAGER]); $existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]);
@@ -344,6 +373,7 @@ class BpnAuthenticatorTest extends TestCase
?User &$persisted, ?User &$persisted,
?string &$persistedPassword = null, ?string &$persistedPassword = null,
?string $contactEmail = null, ?string $contactEmail = null,
?array &$lookupCriteria = null,
): BpnAuthenticator { ): BpnAuthenticator {
$personalData = new PersonalData(); $personalData = new PersonalData();
$personalData->personId = 42; $personalData->personId = 42;
@@ -355,7 +385,14 @@ class BpnAuthenticatorTest extends TestCase
$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);
@@ -12,6 +12,7 @@ use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType; use App\Enum\Groups\AdditionalServiceType;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Model\AccommodationBookingDto; use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingQueryParams; use App\Model\AccommodationBookingQueryParams;
use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationPriceRepository;
@@ -65,6 +66,18 @@ class AccommodationBookingServiceTest extends TestCase
$service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-31', '2026-03-05')); $service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-31', '2026-03-05'));
} }
public function testApplyDatesRejectsAnImplausiblyLongStay(): void
{
// The customer picks from a calendar, so this is a hand-built URL rather than a typo —
// the cap is the same one the backoffice form enforces on the entity.
$service = $this->createServiceWithAccommodation();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Der Aufenthalt darf höchstens %d Nächte umfassen.', AccommodationBooking::MAX_NIGHTS));
$service->applyDates(new AccommodationBookingDto(), '2026-02-28', '2226-03-01');
}
public function testIssueAccessLinkForDirectBookingSetsTimestampForDirectBooking(): void public function testIssueAccessLinkForDirectBookingSetsTimestampForDirectBooking(): void
{ {
$entityManager = $this->createMock(EntityManagerInterface::class); $entityManager = $this->createMock(EntityManagerInterface::class);
@@ -962,6 +975,33 @@ class AccommodationBookingServiceTest extends TestCase
self::assertNull($booking->getTotalPrice(), 'the frozen price belonged to the old house'); self::assertNull($booking->getTotalPrice(), 'the frozen price belonged to the old house');
} }
public function testChangeAccommodationLeavesNothingHalfAppliedWhenTheNewHousePricesImplausibly(): void
{
// The swap and the dropped services are already on the managed entity by the time the
// price is computed, so a refusal has to put the row back rather than leave a booking
// sitting at a new house with no price.
$breakdown = ['total' => AccommodationBooking::MAX_TOTAL_PRICE + 1, 'currency' => 'EUR'];
$breakdownCalculator = $this->createStub(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('computeCurrent')->willReturn($breakdown);
$breakdownCalculator->method('withDiscounts')
->willReturn($breakdown + ['discounts' => [], 'discountedTotal' => AccommodationBooking::MAX_TOTAL_PRICE + 1]);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('refresh');
$entityManager->expects(self::never())->method('flush');
$service = $this->createServiceWithAccommodation(
entityManager: $entityManager,
breakdownCalculator: $breakdownCalculator,
);
$booking = $this->draftWithServices();
$this->expectException(ImplausibleBookingPriceException::class);
$service->changeAccommodation($booking, (new Accommodation())->setName('Berghaus')->setCalendarCode('BERG'));
}
public function testChangeAccommodationNoOpsForANonDraft(): void public function testChangeAccommodationNoOpsForANonDraft(): void
{ {
$entityManager = $this->createMock(EntityManagerInterface::class); $entityManager = $this->createMock(EntityManagerInterface::class);
@@ -1081,6 +1121,57 @@ class AccommodationBookingServiceTest extends TestCase
self::assertSame(['total' => 1000], $ctx->priceBreakdown); self::assertSame(['total' => 1000], $ctx->priceBreakdown);
} }
public function testDuplicatePersistsACopyAndLeavesTheOriginalAlone(): void
{
$booking = (new AccommodationBooking())
->setGroupName('Skiclub Nord')
->setPaxCount(30);
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$persisted = null;
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())
->method('persist')
->willReturnCallback(function (object $entity) use (&$persisted): void { $persisted = $entity; });
$entityManager->expects(self::once())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$copy = $service->duplicate($booking);
self::assertSame($persisted, $copy);
self::assertNotSame($booking, $copy);
self::assertSame(30, $copy->getPaxCount());
self::assertSame(AccommodationBookingStatus::Draft, $copy->getStatus());
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
self::assertSame('Skiclub Nord', $booking->getGroupName());
}
public function testDuplicateMarksTheCopyByName(): void
{
$booking = (new AccommodationBooking())->setGroupName('Skiclub Nord');
$copy = $this->createServiceWithAccommodation()->duplicate($booking);
self::assertSame('Skiclub Nord (Kopie)', $copy->getGroupName());
}
public function testDuplicateNeverRecomputesThePrice(): void
{
// The copy has to show what the original was quoted at, not today's catalog prices.
$booking = new AccommodationBooking();
$booking->setPriceSnapshot(['total' => 500000], 480000, 'CHF', 1);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->expects(self::never())->method('computeCurrent');
$copy = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator)->duplicate($booking);
self::assertSame(['total' => 500000], $copy->getPriceBreakdown());
self::assertSame(480000, $copy->getTotalPrice());
self::assertSame('CHF', $copy->getPricingCurrency());
}
private function createBookingWithContactData(): AccommodationBooking private function createBookingWithContactData(): AccommodationBooking
{ {
return (new AccommodationBooking()) return (new AccommodationBooking())
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\BusProNet\Service\BookingStatusRuleRegistry;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Form\Model\BookingDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingConfigurator;
use App\Service\BookingSessionManager;
use App\Service\ParticipantEligibilityChecker;
use App\Service\TravelDataProvider;
use PHPUnit\Framework\TestCase;
class BookingConfiguratorRoomReconciliationTest extends TestCase
{
public function testKeepsQuantitiesOfRoomsThatAreStillBookable(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2),
$this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 2),
1409 => $this->createRoomSelection(1409, quantity: 0),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertSame([], $adjustments);
$this->assertSame([1408, 1409], array_keys($bookingDto->roomSelections));
$this->assertSame(2, $bookingDto->roomSelections[1408]->quantity);
$this->assertSame(0, $bookingDto->roomSelections[1409]->quantity);
$this->assertSame(2, $bookingDto->roomSelections[1408]->capacity);
$this->assertSame(4, $bookingDto->roomSelections[1408]->maxQuantity);
}
public function testReportsAndRemovesASelectedRoomThatIsNoLongerBookable(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 0, minPax: 2),
$this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 2),
1409 => $this->createRoomSelection(1409, quantity: 0),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertCount(1, $adjustments);
$this->assertSame(1408, $adjustments[0]->roomId);
$this->assertSame(2, $adjustments[0]->requestedQuantity);
$this->assertSame(0, $adjustments[0]->bookableQuantity);
$this->assertTrue($adjustments[0]->isDropped());
$this->assertSame([1409], array_keys($bookingDto->roomSelections));
}
public function testCapsAQuantityThatExceedsTheRemainingContingent(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 1, minPax: 2),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 3),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertCount(1, $adjustments);
$this->assertSame(1408, $adjustments[0]->roomId);
$this->assertSame('Aus der Session', $adjustments[0]->label);
$this->assertSame(3, $adjustments[0]->requestedQuantity);
$this->assertSame(1, $adjustments[0]->bookableQuantity);
$this->assertFalse($adjustments[0]->isDropped());
$this->assertSame(1, $bookingDto->roomSelections[1408]->quantity, 'The selection carries the capped quantity');
$this->assertSame(1, $bookingDto->roomSelections[1408]->maxQuantity);
}
public function testLeavesAQuantityThatStillFitsTheReducedContingent(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 2, minPax: 2),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 2),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertSame([], $adjustments);
$this->assertSame(2, $bookingDto->roomSelections[1408]->quantity);
}
public function testRemovesAnUnselectedRoomThatIsNoLongerBookableWithoutReportingIt(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2),
$this->createRoom(1409, 'Einzelzimmer', available: 0, minPax: 1),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 1),
1409 => $this->createRoomSelection(1409, quantity: 0),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertSame([], $adjustments);
$this->assertSame([1408], array_keys($bookingDto->roomSelections));
}
public function testAddsARoomThatBecameBookableAgain(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2),
$this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 1),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertSame([], $adjustments);
$this->assertSame([1408, 1409], array_keys($bookingDto->roomSelections));
$this->assertSame(0, $bookingDto->roomSelections[1409]->quantity);
$this->assertSame('Einzelzimmer', $bookingDto->roomSelections[1409]->label);
}
public function testEmptiesTheSelectionsWhenTheTravelSoldOutCompletely(): void
{
$travel = $this->createTravel([
$this->createRoom(1408, 'Doppelzimmer', available: 0, minPax: 2),
]);
$bookingDto = new BookingDto($travel, 1);
$bookingDto->roomSelections = [
1408 => $this->createRoomSelection(1408, quantity: 2),
];
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
$this->assertCount(1, $adjustments);
$this->assertTrue($adjustments[0]->isDropped());
$this->assertSame([], $bookingDto->roomSelections);
}
private function bookingService(): BookingConfigurator
{
return new BookingConfigurator(
$this->createStub(BookingSessionManager::class),
$this->createStub(TravelDataProvider::class),
$this->createStub(ParticipantEligibilityChecker::class),
$this->createStub(BookingStatusRuleRegistry::class),
$this->createStub(AgencyLoader::class),
'F'
);
}
/**
* @param array<int, Room> $rooms
*/
private function createTravel(array $rooms): Travel
{
$travel = new Travel();
$travel->id = 4711;
$travel->code = 'TRV-4711';
$travel->rooms = $rooms;
return $travel;
}
private function createRoom(int $id, string $label, int $available, int $minPax): Room
{
$room = new Room();
$room->id = $id;
$room->label = $label;
$room->available = $available;
$room->minPax = $minPax;
$room->status = Constants::STATUS_AVAILABLE;
$room->price = 199.0;
return $room;
}
private function createRoomSelection(int $id, int $quantity): RoomSelectionDto
{
$selection = new RoomSelectionDto();
$selection->id = $id;
$selection->label = 'Aus der Session';
$selection->quantity = $quantity;
$selection->capacity = 2;
$selection->maxQuantity = 9;
return $selection;
}
}
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Travel;
use App\Enum\NewsletterOptInContext;
use App\Exception\NewsletterProviderException;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\BookingNewsletterOptIn;
use App\Service\NewsletterManager;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class BookingNewsletterOptInTest extends TestCase
{
public function testChildrenAreNotOfferedACompanionOptIn(): void
{
$bookingDto = $this->createBookingDto([
['[email protected]', 30],
['[email protected]', 12],
]);
$companions = $this->createService($this->createStub(NewsletterManager::class))
->resolveCompanions($bookingDto, '[email protected]');
self::assertSame([], $companions);
}
public function testApplicantAddressIsNotOfferedAsACompanion(): void
{
$bookingDto = $this->createBookingDto([
['[email protected]', 30],
['[email protected] ', 25],
]);
$companions = $this->createService($this->createStub(NewsletterManager::class))
->resolveCompanions($bookingDto, '[email protected]');
self::assertSame([], $companions);
}
public function testDuplicateAndInvalidCompanionAddressesAreCollapsed(): void
{
$bookingDto = $this->createBookingDto([
['[email protected]', 30],
['[email protected]', 25],
['[email protected]', 26],
['not-an-email', 27],
[null, 28],
]);
$companions = $this->createService($this->createStub(NewsletterManager::class))
->resolveCompanions($bookingDto, '[email protected]');
self::assertCount(1, $companions);
self::assertSame('[email protected]', $companions[0]->email);
}
public function testConfirmedAndPendingCompanionsAreSkipped(): void
{
$companions = $this->createCompanions([
'[email protected]',
'[email protected]',
'[email protected]',
]);
$newsletterManager = $this->createMock(NewsletterManager::class);
$newsletterManager
->method('hasConfirmedOptIn')
->willReturnCallback(static fn (string $email): bool => '[email protected]' === $email);
$newsletterManager
->method('hasPendingConfirmation')
->willReturnCallback(static fn (string $email): bool => '[email protected]' === $email);
$newsletterManager
->expects(self::once())
->method('requestConfirmation')
->with('[email protected]', 'Tim', 'Teiler', NewsletterOptInContext::Companion, 'Mia Muster');
$this->createService($newsletterManager)->subscribeCompanions($companions, 'Mia Muster');
}
public function testOneFailingCompanionDoesNotStopTheOthers(): void
{
$companions = $this->createCompanions([
'[email protected]',
'[email protected]',
]);
$newsletterManager = $this->createMock(NewsletterManager::class);
$newsletterManager->method('hasConfirmedOptIn')->willReturn(false);
$newsletterManager->method('hasPendingConfirmation')->willReturn(false);
$requestedEmails = [];
$newsletterManager
->expects(self::exactly(2))
->method('requestConfirmation')
->willReturnCallback(static function (string $email) use (&$requestedEmails): void {
$requestedEmails[] = $email;
if ('[email protected]' === $email) {
throw new NewsletterProviderException('Mailer down.');
}
});
$this->createService($newsletterManager)->subscribeCompanions($companions, 'Mia Muster');
self::assertSame(['[email protected]', '[email protected]'], $requestedEmails);
}
public function testAFailingConsentLookupDoesNotEscapeToTheCaller(): void
{
$companions = $this->createCompanions([
'[email protected]',
'[email protected]',
]);
$newsletterManager = $this->createMock(NewsletterManager::class);
$newsletterManager
->method('hasConfirmedOptIn')
->willReturnCallback(static function (string $email): bool {
if ('[email protected]' === $email) {
throw new \RuntimeException('Database gone.');
}
return false;
});
$newsletterManager->method('hasPendingConfirmation')->willReturn(false);
$newsletterManager
->expects(self::once())
->method('requestConfirmation')
->with('[email protected]');
$this->createService($newsletterManager)->subscribeCompanions($companions, 'Mia Muster');
}
public function testApplicantOptInFailureDoesNotEscapeToTheCaller(): void
{
$newsletterManager = $this->createStub(NewsletterManager::class);
$newsletterManager
->method('requestConfirmation')
->willThrowException(new \RuntimeException('Database gone.'));
$this->createService($newsletterManager)->subscribeApplicant('[email protected]', null);
$this->expectNotToPerformAssertions();
}
public function testApplicantOptInUsesTheOwnWording(): void
{
$bookingDto = $this->createBookingDto([['[email protected]', 30]]);
$applicant = $bookingDto->participants[0];
$newsletterManager = $this->createMock(NewsletterManager::class);
$newsletterManager
->expects(self::once())
->method('requestConfirmation')
->with('[email protected]', 'Mia', 'Muster');
$this->createService($newsletterManager)->subscribeApplicant('[email protected]', $applicant);
}
public function testRequestedByNameIsTheApplicantsFullName(): void
{
$bookingDto = $this->createBookingDto([['[email protected]', 30]]);
self::assertSame(
'Mia Muster',
$this->createService($this->createStub(NewsletterManager::class))->resolveRequestedByName($bookingDto)
);
}
private function createService(NewsletterManager $newsletterManager): BookingNewsletterOptIn
{
return new BookingNewsletterOptIn($newsletterManager, new NullLogger());
}
/**
* @param list<array{0: string|null, 1: int}> $participants email and age, applicant first
*/
private function createBookingDto(array $participants): BookingDto
{
$bookingDto = new BookingDto(new Travel(), 1);
foreach ($participants as $index => [$email, $age]) {
$participant = new ParticipantDto();
$participant->index = $index;
$participant->firstName = 0 === $index ? 'Mia' : 'Tim';
$participant->lastName = 0 === $index ? 'Muster' : 'Teiler';
$participant->email = $email;
$participant->dateOfBirth = new \DateTimeImmutable(sprintf('-%d years', $age));
$bookingDto->participants[] = $participant;
}
return $bookingDto;
}
/**
* @param list<string> $emails
*
* @return list<ParticipantDto>
*/
private function createCompanions(array $emails): array
{
$companions = [];
foreach ($emails as $offset => $email) {
$participant = new ParticipantDto();
$participant->index = $offset + 1;
$participant->firstName = 'Tim';
$participant->lastName = 'Teiler';
$participant->email = $email;
$participant->dateOfBirth = new \DateTimeImmutable('-25 years');
$companions[] = $participant;
}
return $companions;
}
}
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Service; namespace App\Tests\Service;
use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Pickup;
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;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
@@ -311,6 +313,94 @@ class BookingPricingAssemblerTest extends TestCase
return $participant; return $participant;
} }
public function testBoardingSurchargeIsChargedForBothBusLegs(): void
{
// Travel 12939: two participants boarding and returning at point 18 (€17,90),
// which BusPro bills as two TRA positions of €35,80.
$bookingDto = $this->createBusBookingDto('BUS', 'BUS');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$this->assertNotNull($group, 'Transportation group should exist');
$this->assertEqualsWithDelta(71.6, $group['groupTotal'], 0.001, 'Both legs are charged at €17,90 per participant');
}
public function testBoardingSurchargeSkipsTheReturnLegWhenItIsNotByBus(): void
{
$bookingDto = $this->createBusBookingDto('BUS', 'PKW');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$this->assertEqualsWithDelta(35.8, $group['groupTotal'], 0.001, 'Only the outbound leg is charged');
}
public function testBoardingSurchargeSkipsTheOutboundLegWhenItIsNotByBus(): void
{
$bookingDto = $this->createBusBookingDto('PKW', 'BUS');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$this->assertEqualsWithDelta(35.8, $group['groupTotal'], 0.001, 'Only the return leg is charged');
}
public function testBoardingSurchargeTotalMatchesTheSumOfTheParticipantTotals(): void
{
$bookingDto = $this->createBusBookingDto('BUS', 'BUS');
$group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung');
$participantCalculator = new ParticipantPricingCalculator(new RoomPricingCalculator());
$participantTotal = array_sum($participantCalculator->calculateAllParticipantIndividualPrices($bookingDto));
$this->assertEqualsWithDelta($group['groupTotal'], $participantTotal, 0.001, 'Summary and per-participant totals must agree');
}
private function createBusBookingDto(string $outboundSubType, string $inboundSubType): BookingDto
{
$travel = new Travel();
$travel->insurances = [];
$bookingDto = new BookingDto($travel, 2);
$bookingDto->participants = [];
foreach ([0, 1] as $index) {
$participant = new ParticipantDto();
$participant->index = $index;
$participant->transportationOutbound = $this->createTransportationService(203222, 'Bus-Hinfahrt', $outboundSubType);
$participant->transportationInbound = $this->createTransportationService(203235, 'Bus-Rückfahrt', $inboundSubType);
$participant->pickup = $this->createBoardingPoint(18, 17.9);
$participant->dropOff = $this->createBoardingPoint(18, 17.9);
$bookingDto->participants[] = $participant;
}
return $bookingDto;
}
private function createTransportationService(int $id, string $label, string $subType): Service
{
$service = new Service();
$service->id = $id;
$service->label = $label;
$service->subType = $subType;
$service->price = 0.0;
return $service;
}
private function createBoardingPoint(int $id, float $price): Pickup
{
$pickup = new Pickup();
$pickup->id = $id;
$pickup->city = 'Teststadt';
$pickup->price = $price;
$pickup->priceOutbound = $price;
$pickup->priceInbound = $price;
return $pickup;
}
private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler
{ {
$roomPricingCalculator = new RoomPricingCalculator(); $roomPricingCalculator = new RoomPricingCalculator();
+48
View File
@@ -26,6 +26,8 @@ class DatabaseAnonymizerTest extends TestCase
); );
$user = new User('[email protected]'); $user = new User('[email protected]');
$user->setFirstName('Mia');
$user->setLastName('Muster');
$user->setPassword('plain-text-password'); $user->setPassword('plain-text-password');
$user->setPersonId(12345); $user->setPersonId(12345);
$user->setAddressId(67890); $user->setAddressId(67890);
@@ -162,6 +164,10 @@ class DatabaseAnonymizerTest extends TestCase
$service->anonymizeAccommodationBooking($booking); $service->anonymizeAccommodationBooking($booking);
self::assertStringEndsWith('@example.test', $user->getEmail()); self::assertStringEndsWith('@example.test', $user->getEmail());
self::assertNotSame('Mia', $user->getFirstName());
self::assertNotSame('Muster', $user->getLastName());
self::assertNotNull($user->getFirstName());
self::assertNotNull($user->getLastName());
self::assertSame('plain-text-password', $user->getPassword()); self::assertSame('plain-text-password', $user->getPassword());
self::assertSame(12345, $user->getPersonId()); self::assertSame(12345, $user->getPersonId());
self::assertSame(67890, $user->getAddressId()); self::assertSame(67890, $user->getAddressId());
@@ -254,4 +260,46 @@ class DatabaseAnonymizerTest extends TestCase
self::assertNull($booking->getCity()); self::assertNull($booking->getCity());
self::assertNull($booking->getRemarks()); self::assertNull($booking->getRemarks());
} }
/**
* Most accounts carry no name until the BusPro import fills one in. Minting one here would
* turn an incomplete profile into one that reads as complete while profileComplete still
* says otherwise.
*/
public function testAnonymizeUserLeavesUnsetNamesNull(): void
{
$service = new DatabaseAnonymizer(
$this->createStub(EntityManagerInterface::class),
new NullLogger(),
);
$user = new User('[email protected]');
$service->anonymizeUser($user);
self::assertStringEndsWith('@example.test', (string) $user->getEmail());
self::assertNull($user->getFirstName());
self::assertNull($user->getLastName());
}
/**
* Two accounts of the same person are an intended state, and User::$email is unique. The
* identity is therefore looked up by email alone: resolving it by name too would hand both
* accounts the same synthetic email and break the run on a constraint violation.
*/
public function testAnonymizeUserGivesTwoAccountsOfTheSamePersonDistinctEmails(): void
{
$service = new DatabaseAnonymizer(
$this->createStub(EntityManagerInterface::class),
new NullLogger(),
);
$private = (new User('[email protected]'))->setFirstName('Mia')->setLastName('Muster');
$staff = (new User('[email protected]'))->setFirstName('Mia')->setLastName('Muster');
$service->anonymizeUser($private);
$service->anonymizeUser($staff);
self::assertNotSame($private->getEmail(), $staff->getEmail());
}
} }
+64 -9
View File
@@ -7,6 +7,7 @@ namespace App\Tests\Service;
use App\Email\Mailer; use App\Email\Mailer;
use App\Entity\NewsletterConsent; use App\Entity\NewsletterConsent;
use App\Entity\NewsletterOptInRequest; use App\Entity\NewsletterOptInRequest;
use App\Enum\NewsletterOptInContext;
use App\Exception\NewsletterListNotAllowedException; use App\Exception\NewsletterListNotAllowedException;
use App\Mailjet\ApiClient; use App\Mailjet\ApiClient;
use App\Model\NewsletterConfirmationResult; use App\Model\NewsletterConfirmationResult;
@@ -16,7 +17,6 @@ use App\Repository\NewsletterOptInRequestRepository;
use App\Service\NewsletterManager; use App\Service\NewsletterManager;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
@@ -439,14 +439,69 @@ class NewsletterManagerTest extends TestCase
yield 'boolean' => [true]; yield 'boolean' => [true];
} }
/** public function testRequestConfirmationUsesOwnWordingByDefault(): void
* @param MockObject&NewsletterOptInRequestRepository $repository {
* @param MockObject&NewsletterConsentRepository $consents $repository = $this->createStub(NewsletterOptInRequestRepository::class);
* @param MockObject&EntityManagerInterface $entityManager $consents = $this->createStub(NewsletterConsentRepository::class);
* @param MockObject&ApiClient $mailjet $entityManager = $this->createStub(EntityManagerInterface::class);
* @param MockObject&Mailer $mailer $mailjet = $this->createStub(ApiClient::class);
* @param array<int, string> $mailjetLists $mailer = $this->createMock(Mailer::class);
*/
$repository->method('deleteExpiredPendingByEmail')->willReturn(0);
$repository->method('findPendingByEmail')->willReturn(null);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::callback(static function (array $context): bool {
self::assertNull($context['requestedByName']);
return true;
}),
self::callback(static function (array $options): bool {
self::assertSame('Deine Anmeldung zum E&P Newsletter', $options['subject']);
self::assertSame('email/newsletter_opt_in.html.twig', $options['template']);
return true;
}),
);
$this->createManager($repository, $consents, $entityManager, $mailjet, $mailer)
->requestConfirmation('[email protected]', 'Mia', 'Muster');
}
public function testRequestConfirmationUsesCompanionWordingAndNamesTheRequester(): void
{
$repository = $this->createStub(NewsletterOptInRequestRepository::class);
$consents = $this->createStub(NewsletterConsentRepository::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$mailjet = $this->createStub(ApiClient::class);
$mailer = $this->createMock(Mailer::class);
$repository->method('deleteExpiredPendingByEmail')->willReturn(0);
$repository->method('findPendingByEmail')->willReturn(null);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::callback(static function (array $context): bool {
self::assertSame('Tim', $context['recipientFirstName']);
self::assertSame('Mia Muster', $context['requestedByName']);
return true;
}),
self::callback(static function (array $options): bool {
self::assertSame('Möchtest du den E&P Newsletter erhalten?', $options['subject']);
self::assertSame('email/newsletter_opt_in_companion.html.twig', $options['template']);
return true;
}),
);
$this->createManager($repository, $consents, $entityManager, $mailjet, $mailer)
->requestConfirmation('[email protected]', 'Tim', 'Teiler', NewsletterOptInContext::Companion, 'Mia Muster');
}
private function createManager( private function createManager(
NewsletterOptInRequestRepository $repository, NewsletterOptInRequestRepository $repository,
NewsletterConsentRepository $consents, NewsletterConsentRepository $consents,
+235
View File
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Pickup;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\TravelExport;
use App\Service\TravelExportBuilder;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FileAttributes;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
class TravelExportBuilderTest extends TestCase
{
public function testEmitsOneRowPerTravelDateAndHotelPair(): void
{
$rows = $this->build()->rows;
$keys = array_map(
static fn (array $row): string => $row['busProId'].'-'.$row['hotel']['busProId'],
$rows,
);
self::assertSame(['900-1', '900-2', '901-1'], $keys);
self::assertSame($keys, array_unique($keys), 'the pair the consumer upserts on must be unique export-wide');
}
public function testCarriesTravelAndHotelMasterData(): void
{
$row = $this->rowFor('900-1');
self::assertSame(900, $row['busProId']);
self::assertIsInt($row['busProId']);
self::assertSame('SBW261226', $row['code']);
self::assertSame('Semesterende - Portes du Soleil', $row['product'], 'the product label is trimmed');
self::assertSame('2026-12-26', $row['dateFrom']);
self::assertSame('2026-12-30', $row['dateTo']);
self::assertSame([
'busProId' => 1,
'code' => 'H1',
'name' => 'Hotel Eins',
'costUnit' => '84 85',
'country' => 'CH',
'countryCode' => 'ch',
], $row['hotel']);
}
public function testResolvesBothZeitShapesAndRollsAnOvernightPickupOntoTheNextDay(): void
{
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
self::assertSame('23:59', $pickups[10]['time']);
self::assertSame('2026-12-26T23:59:00+01:00', $pickups[10]['datetime']);
// Time only, so it defaults to the departure date -- which would put it before the
// 23:59 above. The coach cannot board backwards, so it rolls onto the next day.
self::assertSame('02:15', $pickups[11]['time']);
self::assertSame('2026-12-27T02:15:00+01:00', $pickups[11]['datetime']);
}
public function testEmitsTheSummerOffset(): void
{
$pickups = $this->rowFor('901-1')['pickups'];
self::assertSame('2027-07-15T08:00:00+02:00', $pickups[0]['datetime']);
}
public function testATimeOnlyPickupThatAlreadyFollowsTheLastDatedOneKeepsTheDepartureDate(): void
{
$pickups = array_column($this->rowFor('901-1')['pickups'], null, 'busProId');
self::assertSame('2027-07-15T09:30:00+02:00', $pickups[11]['datetime'], 'no roll when the order already holds');
}
public function testKeepsAPickupWithoutAZeitAndNullsBothTimeFields(): void
{
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
self::assertArrayHasKey(12, $pickups);
self::assertNull($pickups[12]['time']);
self::assertNull($pickups[12]['datetime']);
self::assertSame('Z12', $pickups[12]['code'], 'master data still applies to an entry with no time');
}
public function testKeepsAPickupMissingFromMasterDataAndCountsIt(): void
{
$export = $this->build();
$pickups = array_column($export->rows[0]['pickups'], null, 'busProId');
// Dropping it would hide a real departure point from the disposition UI.
self::assertArrayHasKey(99, $pickups);
self::assertNull($pickups[99]['code']);
self::assertNull($pickups[99]['city']);
self::assertNull($pickups[99]['street']);
self::assertSame('21:00', $pickups[99]['time'], 'the time is from the travel XML, not master data');
self::assertSame('2026-12-26T21:00:00+01:00', $pickups[99]['datetime']);
// Counted once, not once per row that references it.
self::assertSame(1, $export->pickupsWithoutMasterData);
}
public function testPickupsKeepSourceOrder(): void
{
$order = array_column($this->rowFor('900-1')['pickups'], 'busProId');
self::assertSame([10, 11, 12, 99], $order, 'source order, never sorted by time');
}
public function testPickupsAreAnEmptyArrayRatherThanNullWhenThereAreNone(): void
{
$rows = $this->build('<?xml version="1.0"?><reisen><reise><termin idbuspro="1" termin="01.01.2027" bis="02.01.2027"><hotel idbuspro="1"/></termin></reise></reisen>')->rows;
self::assertSame([], $rows[0]['pickups']);
}
public function testDropsAndCountsRowsWhoseHotelIsMissingFromMasterData(): void
{
$export = $this->build();
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 902 === $r['busProId']));
// One hotel on travel 902, two on the travel with the unparseable date.
self::assertSame(3, $export->skipped);
}
public function testDropsEveryRowOfATravelWhoseDateCannotBeParsed(): void
{
$export = $this->build();
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 903 === $r['busProId']));
}
public function testCollapsesADuplicatePairLastWins(): void
{
$export = $this->build(null, ['Ziel_1.xml', 'Ziel_2.xml']);
$matching = array_values(array_filter(
$export->rows,
static fn (array $r): bool => 900 === $r['busProId'] && 1 === $r['hotel']['busProId'],
));
self::assertCount(1, $matching);
self::assertSame('Zweiter Treffer', $matching[0]['product']);
}
/**
* @return array<string, mixed>
*/
private function rowFor(string $key): array
{
foreach ($this->build()->rows as $row) {
if ($row['busProId'].'-'.$row['hotel']['busProId'] === $key) {
return $row;
}
}
self::fail('No row for '.$key);
}
/**
* @param list<string> $files
*/
private function build(?string $xml = null, array $files = ['Ziel_1.xml']): TravelExport
{
$contents = [
'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'),
'Ziel_2.xml' => (string) file_get_contents(__DIR__.'/../Resources/travel_export_data_duplicate.xml'),
];
$filesystem = $this->createStub(FilesystemOperator::class);
$filesystem->method('listContents')->willReturn(new DirectoryListing(array_map(
static fn (string $path): FileAttributes => new FileAttributes($path),
$files,
)));
$filesystem->method('read')->willReturnCallback(
static fn (string $path): string => $contents[$path] ?? ''
);
$hotelLoader = $this->createStub(HotelLoader::class);
$hotelLoader->method('loadAll')->willReturn([
1 => $this->hotel(1, 'H1', 'Hotel Eins', '84 85', 'CH', 'ch'),
2 => $this->hotel(2, 'H2', 'Hotel Zwei', null, 'D', 'de'),
]);
$pickupLoader = $this->createStub(PickupLoader::class);
$pickupLoader->method('loadAll')->willReturn([
10 => $this->pickup(10),
11 => $this->pickup(11),
12 => $this->pickup(12),
]);
return (new TravelExportBuilder(
$hotelLoader,
$pickupLoader,
new TravelParser(new AgeConstraintParserRegistry([]), false),
$filesystem,
new TagAwareAdapter(new ArrayAdapter()),
new NullLogger(),
))->build();
}
private function hotel(int $id, string $code, string $name, ?string $costUnit, string $country, string $countryCode): Hotel
{
$hotel = new Hotel();
$hotel->id = $id;
$hotel->code = $code;
$hotel->name = $name;
$hotel->costUnit = $costUnit;
$hotel->country = $country;
$hotel->countryCode = $countryCode;
return $hotel;
}
private function pickup(int $id): Pickup
{
$pickup = new Pickup();
$pickup->id = $id;
$pickup->code = 'Z'.$id;
$pickup->city = 'Stadt '.$id;
$pickup->street = 'Strasse '.$id;
return $pickup;
}
}
+1 -1
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]),
); );
} }