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