Compare commits

..
20 changed files with 1955 additions and 304 deletions

No files matched your search

Generated
+236 -236
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -228,6 +228,11 @@ services:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\ProductExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator:
arguments:
$cache: '@cache.app'
+171 -14
View File
@@ -12,8 +12,8 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
- Access token TTL is **10 minutes** for every grant.
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
A token can therefore expire part-way through a long page walk of `GET /api/exports/travels`; re-request one on
401 and resume at the page you were on. At the default `perPage=500` the whole export is a single page today.
A token can therefore expire part-way through a long page walk of `GET /api/exports/*`; re-request one on
401 and resume at the page you were on. At the default `perPage=500` either export is a single page today.
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
- Send the token as `Authorization: Bearer <access_token>`.
@@ -52,7 +52,7 @@ There is no single error envelope; three shapes occur:
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
`GET /api/exports/travels` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
`GET /api/exports/*` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
@@ -92,7 +92,9 @@ Product master data from the upstream BusProNet system, cached server-side for 1
#### `GET /api/last-update` — scope `api`
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown".
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}` in `Europe/Berlin` local time.
If the export marker is missing or unreadable (for instance mid-sync), the answer is `{"timestamp": null}` with
status 200 — treat null as "unknown", not as "changed".
---
@@ -146,7 +148,7 @@ This is a full overwrite, not a merge.
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
This is the only paginated endpoint and the only one with a `meta` envelope.
The two `/api/exports/*` endpoints are the only paginated ones and the only ones with a `meta` envelope.
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
arbitrary string, so an export hung off that collection would share a namespace with every date code.
@@ -188,7 +190,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"city": "Essen",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"time": "20:15",
"datetime": "2026-12-25T20:15:00+01:00"
"datetime": "2026-12-25T20:15:00"
}
]
}
@@ -200,6 +202,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
"generatedAt": "2026-09-29T12:10:06+02:00"
}
}
@@ -209,8 +212,8 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
empty, never null.
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`
and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
@@ -222,12 +225,17 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
- `time` is `"HH:MM"` for display; `datetime` is the same moment as a full ISO-8601 string with a
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
bare-time sources too, and carries that resolution.
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
an offset to compare or sort them; they already sort correctly as strings.
- Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
takes the departure date (`dateFrom`), and moves to the following day if it would otherwise fall before the
latest full date and time **anywhere** in the same boarding list, whatever its position. So on a departure
dated 26.12. whose list also dates a stop `26.12. 23:59`, a bare `02:15` comes back as `…12-27T02:15:00`.
`datetime` carries that resolution for bare-time sources too.
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
null together.
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
@@ -238,6 +246,155 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the
same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can
replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on
page 1, you have stitched two exports together. Discard what you have and restart from page 1.
#### `GET /api/exports/products` — scope `api`
Bulk export of every travel date × hotel pair with its **whole record**, for clients that import the product
catalogue: rooms with prices and availability, the pseudo price, all selection groups, the three service lists,
boarding points with prices, and the guide. Same grain, paging, query parameters, `meta` envelope, status codes and
page-consistency rule as `GET /api/exports/travels` above; only the rows differ.
**Unfiltered on purpose.** Nothing is dropped or rewritten for you. Return-leg (`RUECK`) services, `EIN` services,
`Buchungsstop` rooms and services, rooms without a price and boarding points with id `0` are all emitted. Which of
them a website shows, and how, is the client's rule, not this endpoint's.
At current volumes the export is ~420 rows and ~3.8 MB of JSON (a row is 4–24 kB), so the default `perPage=500`
returns it in one page.
```json
{
"data": [
{
"product": { "code": "DKIWE", "busProId": 1127 },
"date": {
"code": "DKIWE041226",
"busProId": 12437,
"from": "2026-12-04",
"to": "2026-12-06",
"type": "F"
},
"hotel": { "code": "DKIWEKT", "busProId": 222931 },
"pseudoPrice": null,
"selectionGroups": [
{
"busProId": 7,
"label": "Leistungen Skipass",
"selections": [ { "busProId": 92, "label": "3 Tage Skipass" } ]
}
],
"transportServices": [
{ "busProId": 193804, "subType": "PKW", "label": "Eigene Anreise mit Rabatt", "price": -50, "status": "Frei", "direction": "HIN" }
],
"dateServices": [
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
],
"hotelServices": [
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
],
"rooms": [
{
"busProId": 74,
"code": "MBZET",
"label": "Bett im Mehrbettzimmer Etagendusche/-WC",
"category": "GR",
"minPax": 1,
"maxPax": 1,
"nights": 2,
"price": 229,
"available": 24,
"status": "Frei",
"boardCode": "Lt. Auss.",
"boardBusProId": 2
}
],
"pickups": [
{
"busProId": 4,
"code": "FRA-Route",
"city": "Frankfurt",
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
"price": 5.9,
"datetime": "2026-12-03T23:30:00"
}
],
"guide": null
}
],
"meta": {
"page": 1,
"perPage": 500,
"total": 419,
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-30T11:11:01+02:00",
"generatedAt": "2026-09-30T14:05:00+02:00"
}
}
```
**Row identity.** `(date.busProId, hotel.busProId)` is unique across the whole export — upsert on that pair.
`product.code` and `hotel.code` are verbatim and suitable for matching against your own records. No row depends on
hotel master data, so `skipped` counts only travel dates whose dates could not be parsed.
**Types.**
- Every price is a JSON **number** in euros, never a formatted string: `229`, `5.4`, `-50`. A price the source does
not state is `null`, and `0` means a stated zero ("included"). Do not treat the two alike.
- `selectionGroups`, `selections`, `transportServices`, `dateServices`, `hotelServices`, `rooms` and `pickups` are
always arrays, possibly empty, never null. `pseudoPrice` and `guide` are nullable.
- All arrays are in **source order**. Sort for display yourself; do not depend on the order.
**Rooms.** One entry per room offered on the date at that hotel.
- `code` is trimmed. `available` is a count of **rooms**, not persons — person capacity is `available × maxPax`.
- `status` is passed through verbatim: `Frei`, `Anfrage` or `Buchungsstop`.
- `available`, `price`, `minPax`, `maxPax`, `nights` and `boardBusProId` are `null` where the source leaves them
empty.
**`pseudoPrice`.** A struck-through comparison price for the date, carried upstream by a pseudo room with code
`PDGS`. It is lifted onto the row and never appears in `rooms`. It is `null` in most rows.
**Services.** Three lists, deliberately kept apart:
- `transportServices` — the date's transport services (bus, own arrival, train …). Both directions.
- `dateServices` — the date's general services.
- `hotelServices` — the services listed on this hotel for this date. Upstream repeats most date-level services
here, possibly with hotel-specific prices, so expect overlap with `dateServices`. If you merge the two, key by
`busProId`: it identifies a service across both lists.
`subType` is the upstream service subtype (`BUS`, `PKW`, `BAH`, `SON`, `SPA`, `VER`, `EIN`, …), passed through.
`direction` is `HIN` or `RUECK` on transport services and `null` on general ones. A negative `price` is a discount,
such as for arriving by car.
**Selection groups.** Every group on the date, not only the `Leistungen *` ones. Match groups by `busProId`, not by
`label`: labels are editorial and can be renamed upstream. The ids of the included-services groups are stable:
| `busProId` | Label |
|---|---|
| 7 | Leistungen Skipass |
| 8 | Leistungen Sonstige |
| 71 | Leistungen Verpflegung |
| 85 | Leistungen Übernachtung |
| 86 | Leistungen Programm |
| 87 | Leistungen Kurse |
| 89 | Leistungen Events |
| 90 | Leistungen Transport |
**Pickups.** Outbound boarding points only, in source order. `datetime` is resolved and formatted exactly as in
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for
display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states
none. It is not levelled against the return leg. An id absent from
`GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id
of `0` is emitted as-is but not counted.
**`guide`.** `{ "name": …, "phone": … }` for the first outbound bus service that has a guide assigned, else `null`.
---
### Travels
+23
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
final readonly class XmlExportInfo
{
private const INFO_FILENAME = 'uebertragung.info';
@@ -20,6 +23,26 @@ final readonly class XmlExportInfo
return self::INFO_FILENAME;
}
/**
* Reads the marker from a storage, or null when it is missing or unreadable.
*
* A marker caught mid-sync or truncated is treated as absent rather than as an error:
* callers only ever want to know which export they are looking at, and "unknown" is
* an answer they can act on.
*/
public static function fromStorage(FilesystemOperator $storage): ?self
{
try {
if (false === $storage->fileExists(self::INFO_FILENAME)) {
return null;
}
return self::fromString($storage->read(self::INFO_FILENAME));
} catch (FilesystemException|\InvalidArgumentException) {
return null;
}
}
public static function fromString(string $content): self
{
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
+5 -2
View File
@@ -79,6 +79,9 @@ trait TypeConversionTrait
* This method is used primarily to parse pickup-times since data is
* either provided with date and time or time only. In this case a
* date has to be provided which defaults to a travel's start date.
*
* Both shapes are read in the default date's zone, so a caller that needs plain clock
* readings can pass a UTC date and no daylight saving gap shifts the time.
*/
protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
{
@@ -89,12 +92,12 @@ trait TypeConversionTrait
try {
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
// time only provided
$dateTime = Carbon::createFromFormat('H:i', $string);
$dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
// assign default date
$dateTime->setDateFrom($defaultDate);
} else {
// date and time provided
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16));
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16), $defaultDate->getTimezone());
}
return $dateTime->toDateTimeImmutable();
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Constants;
use Symfony\Component\DomCrawler\Crawler;
/**
* Reads the parts of a `Ziel_*.xml` travel date that the product export carries, verbatim.
*
* Deliberately separate from TravelParser, which builds the booking path's models and
* applies the booking path's rules: it drops the PDGS pseudo room, excludes the
* hotel-nested services and levels boarding-point prices. The product export filters and
* rewrites nothing -- its consumer applies its own rules -- so it reads the nodes directly
* and returns plain arrays in source order.
*
* @see \App\Service\ProductExportBuilder
*/
class ProductExportParser extends AbstractParser
{
/**
* Every selection group on the date, not only the `Leistungen *` ones.
*
* @return list<array{busProId: int, label: ?string, selections: list<array{busProId: int, label: ?string}>}>
*/
public function getSelectionGroups(Crawler $dateNode): array
{
return $this->children($dateNode, 'selektiongruppe')->each(fn (Crawler $group): array => [
'busProId' => (int) $group->attr('idbuspro'),
'label' => $group->attr('bezeichnung'),
'selections' => $this->children($group, 'selektion')->each(fn (Crawler $selection): array => [
'busProId' => (int) $selection->attr('idbuspro'),
'label' => $selection->attr('bezeichnung'),
]),
]);
}
/**
* Services of one `lei_*` list, keyed by nothing -- the consumer keys them by `busProId`.
*
* The per-list `id` attribute is not read: it restarts at 1 in every list, so it
* identifies nothing once a date-level and a hotel-level list are merged.
*
* @return list<array{busProId: int, subType: ?string, label: ?string, price: ?float, status: ?string, direction: ?string}>
*/
public function getServices(Crawler $serviceNodes): array
{
return $serviceNodes->each(fn (Crawler $service): array => [
'busProId' => (int) $service->attr('idbuspro'),
'subType' => $service->attr('unterart'),
'label' => $this->getStringOrNullValue($this->children($service, 'text')),
'price' => $this->getFloatOrNullValue($this->children($service, 'preis')),
'status' => $this->getStringOrNullValue($this->children($service, 'status')),
'direction' => $this->getStringOrNullValue($this->children($service, 'richtung')),
]);
}
/**
* The hotel's rooms, without the PDGS pseudo room.
*
* `available` is a count of rooms, not persons. Occupancy is read case-insensitively:
* the travel export writes `MinPax`/`MaxPax`.
*
* @return list<array<string, mixed>>
*/
public function getRooms(Crawler $hotelNode): array
{
$rooms = [];
$this->children($hotelNode, 'zimmer/preis')->each(function (Crawler $room) use (&$rooms) {
$code = $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'));
if (Constants::PSEUDO_PRICE_ROOM_CODE === $code) {
return;
}
$rooms[] = [
'busProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_zimmer'),
'code' => $code,
'label' => $this->getAttrOrNullValue($room, 'zimmertext'),
'category' => $this->getAttrOrNullValue($room, 'kat'),
'minPax' => $this->getIntAttrOrNullValue($room, 'minpax'),
'maxPax' => $this->getIntAttrOrNullValue($room, 'maxpax'),
'nights' => $this->getIntAttrOrNullValue($room, 'naechte'),
'price' => $this->stringToFloat($this->getAttrOrNullValue($room, 'preis')),
'available' => $this->getIntAttrOrNullValue($room, 'verfuegbar'),
'status' => $this->getAttrOrNullValue($room, 'status'),
'boardCode' => $this->getAttrOrNullValue($room, 'vpcode'),
'boardBusProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_vp'),
];
});
return $rooms;
}
/**
* The struck-through comparison price, carried by the PDGS pseudo room.
*/
public function getPseudoPrice(Crawler $hotelNode): ?float
{
foreach ($this->children($hotelNode, 'zimmer/preis') as $room) {
$room = new Crawler($room);
if (Constants::PSEUDO_PRICE_ROOM_CODE === $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'))) {
return $this->stringToFloat($this->getAttrOrNullValue($room, 'preis'));
}
}
return null;
}
/**
* The guide of the first outbound bus that has one.
*
* Not TravelParser::getGuide(), which takes the first guide of any bus, return leg
* included.
*
* @return array{name: ?string, phone: ?string}|null
*/
public function getGuide(Crawler $dateNode): ?array
{
$guides = $this->children($dateNode, 'lei_befoerderung/leistung[@unterart="BUS"][not(richtung="RUECK")]/zustiegsplanung/reiseleiter');
if (0 === $guides->count()) {
return null;
}
$guide = $guides->first();
return [
'name' => $this->getStringOrNullValue($this->children($guide, 'name')),
'phone' => $this->getStringOrNullValue($this->children($guide, 'telefon')),
];
}
/**
* A path below the node's own children, never deeper.
*
* DomCrawler evaluates filterXPath() against a fake parent of the node, so `./x` finds
* nothing and `.//x` searches every descendant -- which would pull a hotel's
* `lei_sonstiges` into the date's list. A leading `*` step anchors the path at the
* node itself, whatever its name.
*/
public function children(Crawler $node, string $path): Crawler
{
return $node->filterXPath('*/'.$path);
}
private function trimOrNull(?string $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim($value);
return '' === $trimmed ? null : $trimmed;
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
/**
* Removes local accounts that carry nothing worth keeping.
*
* Nothing here destroys customer data: BusPro owns the person, the bookings and the roles, and
* BpnAuthenticator rebuilds the local row on the next login (`findOneBy(email) ?? new User()`),
* re-deriving the roles, the hotel codes and the profile completeness from the CRM as it goes.
* An account is a cache of what BusPro says, so dropping one costs its owner nothing.
*
* One thing is not rebuilt, and it is the reason every removal gets a log line of its own: the
* uuid, minted in the constructor and exported as the OAuth2 `sub` claim (see UserinfoController).
* A returning account gets a fresh one, so the old subject cannot be tied to the new row by
* anything except that log entry. The SSO clients that gate on a role — E&P Team, E&P Hub — never
* see these accounts anyway, since the roles they require are exactly the ones excluded below.
*
* An account is removed when all of the following hold:
*
* - it holds no effective role beyond ROLE_CUSTOMER, that role being the fallback for an account
* with no privileges at all (see Role::withCustomerFallback()). An account left with no roles
* at all is the same state, spelled the way it was before the fallback landed;
* - it carries no pending nomination, which would otherwise disappear from /admin/user before
* anybody had ruled on it;
* - it holds no booking edit draft, and nothing in the Groups subsystem names it.
*
* --inactive-for adds a "has not signed in for N months" condition on top. It is off by default:
* a returning customer is recreated on sight, so age says nothing about whether a row may go.
*
* Deleting requires --force. The removal cannot be undone and the first run clears thousands of
* rows, so a bare invocation reports what it would do and stops. The guard is a flag rather than
* an $io->confirm() because the scheduler hands the command a StringInput, which reports itself
* as interactive — a confirmation would sit waiting on a STDIN nobody is attached to. A flag
* behaves the same way whether a person or cron is running it, and it makes the schedule entry
* say out loud that the task destroys data.
*/
#[AsCommand(
name: 'app:cleanup:users',
description: 'Removes accounts that hold no role, no draft and no group booking reference',
)]
class CleanupUsersCommand extends Command
{
/**
* How many accounts are removed between two flushes. The unit of work holds every scheduled
* removal until then, and the first run has thousands of rows to get through, so it is a
* memory bound rather than a policy one.
*/
private const BATCH_SIZE = 500;
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'inactive-for',
null,
InputOption::VALUE_REQUIRED,
'Only consider accounts without a login for this many months (default: no such limit)',
)
->addOption(
'limit',
null,
InputOption::VALUE_REQUIRED,
'Maximum number of accounts to delete in one run (default: no limit)',
)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting')
->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete; without it the command only reports what it would do')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$months = null !== $input->getOption('inactive-for') ? (int) $input->getOption('inactive-for') : null;
$limit = null !== $input->getOption('limit') ? (int) $input->getOption('limit') : null;
$dryRun = (bool) $input->getOption('dry-run');
$force = (bool) $input->getOption('force');
if ((null !== $months && $months < 1) || (null !== $limit && $limit < 1)) {
$io->error('--inactive-for and --limit have to be at least 1 when given.');
return Command::INVALID;
}
$inactiveSince = null !== $months ? new \DateTimeImmutable(sprintf('-%d months', $months)) : null;
$candidates = array_values(array_filter(
$this->userRepository->findCleanupCandidates($inactiveSince),
static fn (User $user): bool => self::isDisposable($user),
));
// After the role filter, never before it: --limit caps the accounts actually deleted, and
// the candidate query cannot tell which ones those are.
if (null !== $limit) {
$candidates = \array_slice($candidates, 0, $limit);
}
if ([] === $candidates) {
$io->success('No accounts to remove.');
return Command::SUCCESS;
}
if (true === $dryRun || false === $force) {
$io->table(
['E-Mail', 'Letzter Login', 'Rollen'],
array_map(static fn (User $user): array => [
$user->getEmail(),
$user->getLastLoginAt()?->format('Y-m-d') ?? '-',
implode(', ', Role::assignedOnly($user->getRoles())) ?: '-',
], $candidates),
);
$io->note(sprintf('Would delete %d account(s).', \count($candidates)));
if (true === $dryRun) {
return Command::SUCCESS;
}
// Not a dry run, just an invocation that forgot to say so. Reported as a failure so a
// schedule entry missing --force turns into a mail rather than a task that quietly
// does nothing every week.
$io->error('Refusing to delete without --force. Pass --dry-run to preview, or --force to delete.');
return Command::INVALID;
}
foreach ($candidates as $index => $user) {
// One line per account, before the removal rather than after: once the row is gone
// this is the only place left that records which account the uuid belonged to. Worth
// a log_entry row per deletion, including the several thousand the first run writes —
// they age out with the six-month log retention.
$this->logger->info('Deleting account without roles', [
'uuid' => $user->getUuid(),
'email' => $user->getEmail(),
'lastLoginAt' => $user->getLastLoginAt()?->format(\DateTimeInterface::ATOM),
'roles' => Role::assignedOnly($user->getRoles()),
]);
$this->entityManager->remove($user);
if (0 === ($index + 1) % self::BATCH_SIZE) {
$this->entityManager->flush();
}
}
$this->entityManager->flush();
$count = \count($candidates);
$this->logger->info('Deleted accounts without roles', [
'count' => $count,
'inactiveSince' => $inactiveSince?->format('Y-m-d'),
]);
$io->success(sprintf('Deleted %d account(s).', $count));
return Command::SUCCESS;
}
/**
* Whether the account's role set says it may go. ROLE_CUSTOMER is the fallback rather than a
* baseline, so "customer only" and "nothing at all" are the same state — the second being the
* spelling accounts kept that have not signed in since the fallback landed.
*
* Pending markers grant nothing, but they are a nomination waiting for an administrator, so an
* account carrying one stays until somebody has ruled on it.
*/
private static function isDisposable(User $user): bool
{
$roles = $user->getRoles();
return [] === Role::pendingOnly($roles)
&& [] === array_diff(Role::effectiveOnly($roles), [Role::CUSTOMER]);
}
}
+25 -3
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Api;
use App\Model\TravelExportQuery;
use App\Model\Export;
use App\Model\ExportQuery;
use App\Service\ProductExportBuilder;
use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -29,6 +31,7 @@ class ExportController extends AbstractController
{
public function __construct(
private readonly TravelExportBuilder $travelExportBuilder,
private readonly ProductExportBuilder $productExportBuilder,
) {
}
@@ -41,13 +44,31 @@ class ExportController extends AbstractController
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
public function travels(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
TravelExportQuery $query = new TravelExportQuery(),
ExportQuery $query = new ExportQuery(),
): JsonResponse {
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
// status codes, so a failure here has to surface as a 5xx.
$export = $this->travelExportBuilder->build();
return $this->paginate($this->travelExportBuilder->build(), $query);
}
/**
* Every travel date x hotel pair with its whole record, unfiltered, paginated.
*
* The website's product import. Same grain, envelope and failure behaviour as travels();
* the rows carry rooms, prices, services and selection groups instead of the
* disposition subset, and nothing is dropped for the consumer.
*/
#[Route(path: '/products', name: 'api_exports_products', methods: ['GET'])]
public function products(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ExportQuery $query = new ExportQuery(),
): JsonResponse {
return $this->paginate($this->productExportBuilder->build(), $query);
}
private function paginate(Export $export, ExportQuery $query): JsonResponse
{
$total = count($export->rows);
return $this->json([
@@ -59,6 +80,7 @@ class ExportController extends AbstractController
'totalPages' => (int) ceil($total / $query->perPage),
'skipped' => $export->skipped,
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
'sourceGeneratedAt' => $export->sourceGeneratedAt?->format('c'),
'generatedAt' => $export->generatedAt->format('c'),
],
]);
+7 -14
View File
@@ -2,6 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\Model\XmlExportInfo;
use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -16,23 +17,15 @@ class LastUpdateController extends AbstractController
{
}
/**
* A missing or unreadable marker answers `{"timestamp": null}`, never a 500: consumers
* poll this for change detection and treat null as "unknown".
*/
#[Route(path: '/last-update', name: 'api_last_update', methods: ['GET'])]
public function index(): JsonResponse
{
if (false === $this->xmlExport->has('uebertragung.info')) {
$lastUploadAt = null;
} else {
$file = $this->xmlExport->read('uebertragung.info');
$lines = explode("\n", $file);
$line = trim($lines[0]);
$info = XmlExportInfo::fromStorage($this->xmlExport);
if (true === empty($line)) {
$lastUploadAt = null;
} else {
$lastUploadAt = \DateTime::createFromFormat('d.m.Y H:i:s', $line);
}
}
return $this->json(['timestamp' => $lastUploadAt->format('Y-m-d H:i:s')]);
return $this->json(['timestamp' => $info?->lastTransfer->format('Y-m-d H:i:s')]);
}
}
@@ -5,26 +5,29 @@ declare(strict_types=1);
namespace App\Model;
/**
* The whole travel-date x hotel export plus the counters that describe how it was built.
* A whole travel-date x hotel export plus the counters that describe how it was built.
*
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
* makes the counters identical on every page: they describe the export, not the page, so a
* consumer can read them once and abort the page walk on the first response.
*
* @see \App\Service\TravelExportBuilder
* @see \App\Service\ProductExportBuilder
*/
final readonly class TravelExport
final readonly class Export
{
/**
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
* @param int $skipped (travel, hotel) pairs dropped as unusable
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
* @param \DateTimeImmutable|null $sourceGeneratedAt the uebertragung.info timestamp of the export read, null if unknown
*/
public function __construct(
public array $rows,
public int $skipped,
public int $pickupsWithoutMasterData,
public \DateTimeImmutable $generatedAt,
public ?\DateTimeImmutable $sourceGeneratedAt = null,
) {
}
}
@@ -6,7 +6,7 @@ namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class TravelExportQuery
final readonly class ExportQuery
{
public function __construct(
#[Assert\Positive]
+88
View File
@@ -4,7 +4,12 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookingEditDraft;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Entity\User;
use App\Form\Model\Filter\UserFilterDto;
use App\Repository\Filter\AppliesListFiltersTrait;
@@ -122,4 +127,87 @@ class UserRepository extends ServiceEntityRepository
return $users;
}
/**
* The entities that point at a User and would silently lose that pointer if the account were
* deleted. Every one of these join columns is ON DELETE SET NULL, so the database does not
* stand in the way — it just erases who created, last touched or looks after a group booking.
* The cleanup therefore has to ask, rather than let the constraint decide.
*
* @var array<class-string, string[]>
*/
private const USER_REFERENCES = [
Accommodation::class => ['createdBy', 'updatedBy'],
AccommodationPrice::class => ['createdBy', 'updatedBy'],
AccommodationBooking::class => ['createdBy', 'updatedBy', 'managedBy'],
AdditionalService::class => ['createdBy', 'updatedBy'],
BoardService::class => ['createdBy', 'updatedBy'],
];
/**
* Accounts the cleanup may consider: holding no booking edit draft and named by nothing in the
* Groups subsystem. $inactiveSince narrows that to accounts dormant since then, and is the
* caller's choice rather than this method's default — BpnAuthenticator recreates an account on
* sight, so how long ago somebody last signed in says nothing about whether the row may go.
*
* Deliberately unbounded: the caller still has to drop everything whose roles disqualify it,
* so a LIMIT here would cap the rows examined rather than the rows acted on, and a caller
* asking for ten deletions would get however many of the first ten candidates survived the
* role filter. Ordered oldest login first so a caller that does cap the result takes the
* least recently seen accounts.
*
* The roles are deliberately not part of this query. They live in a JSON array column with no
* JSON_CONTAINS registered, and the column holds more than one spelling of the same set — rows
* written by different Doctrine versions differ in whitespace — so a LIKE or an equality test
* on the raw JSON would quietly miss accounts or, far worse, match the wrong ones. The caller
* filters the shortlist through Role::effectiveOnly(), which is the only thing that knows what
* a role set actually means.
*
* @return User[]
*/
public function findCleanupCandidates(?\DateTimeImmutable $inactiveSince = null): array
{
$qb = $this->createQueryBuilder('u');
if (null !== $inactiveSince) {
$qb
->andWhere('u.lastLoginAt IS NOT NULL')
->andWhere('u.lastLoginAt < :inactiveSince')
->setParameter('inactiveSince', $inactiveSince)
;
}
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
$this->getEntityManager()->createQueryBuilder()
->select('draft.id')
->from(BookingEditDraft::class, 'draft')
->andWhere('draft.user = u')
->getDQL()
)));
$index = 0;
foreach (self::USER_REFERENCES as $class => $fields) {
foreach ($fields as $field) {
$alias = sprintf('ref%d%s', $index++, $field);
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
$this->getEntityManager()->createQueryBuilder()
->select($alias.'.id')
->from($class, $alias)
->andWhere(sprintf('%s.%s = u', $alias, $field))
->getDQL()
)));
}
}
/** @var User[] $users */
$users = $qb
->orderBy('u.lastLoginAt', 'ASC')
->getQuery()
->getResult()
;
return $users;
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\XmlExportInfo;
use App\BusProNet\Traits\TypeConversionTrait;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\ProductExportParser;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\Export;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Flattens the BusPro XML export into one row per travel date x hotel, the whole record.
*
* The sibling of TravelExportBuilder at the same grain, for the website's product import
* rather than the disposition. It filters nothing: return legs, `EIN` services,
* `Buchungsstop` rooms and pickups without an id all reach the consumer, whose display
* rules decide what to drop. Folding those rules in here would make every change to the
* website a change to this endpoint.
*
* Hotels are identified by the code and id on the travel XML's own `<hotel>` node, so
* unlike the travel export no row depends on hotel.xml master data.
*
* Boarding-point prices are the export's, verbatim. APP_BPN_LEVEL_BOARDING_POINT_PRICES
* is a booking-path compensation and is deliberately not applied: TravelParser::getPickups()
* does not level, only TravelParser::parse() does.
*/
class ProductExportBuilder
{
use TypeConversionTrait;
private const CACHE_KEY = 'bpn_products_export';
public function __construct(
private readonly PickupLoader $pickupLoader,
private readonly TravelParser $travelParser,
private readonly ProductExportParser $productExportParser,
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
) {
}
/**
* Assembles the whole export, cached as one unit alongside the sibling loaders.
*/
public function build(): Export
{
try {
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
$item->expiresAfter(3 * 60 * 60);
$item->tag(['xml-sync']);
return $this->assemble();
});
} catch (InvalidArgumentException $e) {
throw new \RuntimeException('Cache key error in ProductExportBuilder', 0, $e);
}
}
private function assemble(): Export
{
// Read before the walk and cached with the rows; see TravelExportBuilder::assemble().
$sourceGeneratedAt = XmlExportInfo::fromStorage($this->xmlExport)?->lastTransfer;
$pickupMasterData = $this->pickupLoader->loadAll();
/** @var array<string, array<string, mixed>> $rows keyed by dateId-hotelId */
$rows = [];
$skipped = 0;
/** @var array<int, true> $pickupsWithoutMasterData */
$pickupsWithoutMasterData = [];
$files = $this
->xmlExport
->listContents('.')
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
foreach ($files as $file) {
$crawler = XmlCrawlerFactory::create($this->xmlExport->read($file->path()));
$crawler->filterXPath('//reisen/reise')->each(
function (Crawler $productNode) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $pickupMasterData) {
$product = [
'code' => $productNode->attr('code'),
'busProId' => null !== $productNode->attr('idbuspro') ? (int) $productNode->attr('idbuspro') : null,
];
$this->productExportParser->children($productNode, 'termin')->each(
function (Crawler $dateNode) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $pickupMasterData, $product) {
$this->collectDate($dateNode, $product, $rows, $skipped, $pickupsWithoutMasterData, $pickupMasterData);
}
);
}
);
}
return new Export(
array_values($rows),
$skipped,
count($pickupsWithoutMasterData),
new \DateTimeImmutable(),
$sourceGeneratedAt,
);
}
/**
* @param array{code: ?string, busProId: ?int} $product
* @param array<string, array<string, mixed>> $rows
* @param array<int, true> $pickupsWithoutMasterData
* @param array<int|string, Pickup> $pickupMasterData
*/
private function collectDate(
Crawler $dateNode,
array $product,
array &$rows,
int &$skipped,
array &$pickupsWithoutMasterData,
array $pickupMasterData,
): void {
$hotelNodes = $this->productExportParser->children($dateNode, 'hotel');
$dateFrom = $this->stringToDate($dateNode->attr('termin'));
$dateTo = $this->stringToDate($dateNode->attr('bis'));
if (null === $dateFrom || null === $dateTo) {
$this->logger->warning('Product export: unparseable travel date', [
'travel_id' => $dateNode->attr('idbuspro'),
'termin' => $dateNode->attr('termin'),
'bis' => $dateNode->attr('bis'),
]);
$skipped += $hotelNodes->count();
return;
}
$dateId = (int) $dateNode->attr('idbuspro');
// Everything above the hotel is shared by the date's hotel rows, so it is read once.
$shared = [
'product' => $product,
'date' => [
'code' => $dateNode->attr('code'),
'busProId' => $dateId,
'from' => $dateFrom->format('Y-m-d'),
'to' => $dateTo->format('Y-m-d'),
'type' => $dateNode->attr('reiseart'),
],
];
$dateLevel = [
'selectionGroups' => $this->productExportParser->getSelectionGroups($dateNode),
'transportServices' => $this->productExportParser->getServices($this->productExportParser->children($dateNode, 'lei_befoerderung/leistung')),
'dateServices' => $this->productExportParser->getServices($this->productExportParser->children($dateNode, 'lei_sonstiges/leistung')),
];
$pickups = $this->buildPickups($dateNode, $dateFrom, $pickupMasterData, $pickupsWithoutMasterData);
$guide = $this->productExportParser->getGuide($dateNode);
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, $shared, $dateLevel, $pickups, $guide, $dateId) {
$hotelId = (int) $hotelNode->attr('idbuspro');
// Keyed by the pair the consumer upserts on, so a date repeated across files
// collapses last-wins instead of reaching it as a duplicate.
$rows[$dateId.'-'.$hotelId] = $shared + [
'hotel' => [
'code' => $hotelNode->attr('code'),
'busProId' => $hotelId,
],
'pseudoPrice' => $this->productExportParser->getPseudoPrice($hotelNode),
] + $dateLevel + [
// The list nested in this hotel, not the date-level one: the consumer
// merges the two per hotel.
'hotelServices' => $this->productExportParser->getServices($this->productExportParser->children($hotelNode, 'lei_sonstiges/leistung')),
'rooms' => $this->productExportParser->getRooms($hotelNode),
'pickups' => $pickups,
'guide' => $guide,
];
});
}
/**
* Outbound boarding points joined against zustiege.xml, in source order.
*
* getPickups() resolves both zeit shapes and rolls a bare time past midnight onto the
* next day. Entries with id 0 are kept: the consumer drops them itself.
*
* `datetime` is BusPro's clock reading, without an offset. Resolving in Europe/Berlin
* would move a bare 02:45 on the spring-forward night to 03:45, so the dates are
* resolved in UTC, where no time is skipped or repeated.
*
* @param array<int|string, Pickup> $masterData
* @param array<int, true> $pickupsWithoutMasterData
*
* @return list<array<string, mixed>>
*/
private function buildPickups(Crawler $dateNode, \DateTimeImmutable $dateFrom, array $masterData, array &$pickupsWithoutMasterData): array
{
$resolved = [];
$clockDate = new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC'));
foreach ($this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege/zustieg'), $clockDate, true) as $pickup) {
$pickupId = (int) $pickup->id;
$details = $masterData[$pickupId] ?? null;
if (null === $details && 0 !== $pickupId) {
$pickupsWithoutMasterData[$pickupId] = true;
}
$resolved[] = [
'busProId' => $pickupId,
'code' => $details?->code,
'city' => $details?->city,
'street' => $details?->street,
'price' => $pickup->price,
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
];
}
return $resolved;
}
}
+22 -7
View File
@@ -6,12 +6,13 @@ namespace App\Service;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\XmlExportInfo;
use App\BusProNet\Traits\TypeConversionTrait;
use App\BusProNet\XmlCrawlerFactory;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\TravelExport;
use App\Model\Export;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
@@ -51,7 +52,7 @@ class TravelExportBuilder
/**
* Assembles the whole export, cached as one unit alongside the sibling loaders.
*/
public function build(): TravelExport
public function build(): Export
{
try {
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
@@ -65,8 +66,13 @@ class TravelExportBuilder
}
}
private function assemble(): TravelExport
private function assemble(): Export
{
// Read before the walk and cached with the rows, so every page served from this
// copy names the same source export. A client that sees it change between pages
// knows xml-sync swapped the export underneath its walk.
$sourceGeneratedAt = XmlExportInfo::fromStorage($this->xmlExport)?->lastTransfer;
$hotels = $this->hotelLoader->loadAll();
$pickupMasterData = $this->pickupLoader->loadAll();
@@ -91,11 +97,12 @@ class TravelExportBuilder
);
}
return new TravelExport(
return new Export(
array_values($rows),
$skipped,
count($pickupsWithoutMasterData),
new \DateTimeImmutable(),
$sourceGeneratedAt,
);
}
@@ -137,8 +144,15 @@ class TravelExportBuilder
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
// calendar day. Resolved once per travel date, then shared by its hotel rows.
//
// Resolved in UTC: the times are BusPro's clock readings, and in Europe/Berlin a
// bare 02:45 on the spring-forward night would move to 03:45.
$pickups = $this->buildPickups(
$this->travelParser->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true),
$this->travelParser->getPickups(
$node->filterXPath('.//zustiege/zustieg'),
new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC')),
true,
),
$pickupMasterData,
$pickupsWithoutMasterData,
);
@@ -227,8 +241,9 @@ class TravelExportBuilder
'street' => $details?->street,
'time' => $pickup->time?->format('H:i'),
// Emitted for time-only sources too: adjustOvernightTimes() has already
// worked out the real day, and a null here would throw that away.
'datetime' => $pickup->time?->format('c'),
// worked out the real day, and a null here would throw that away. A clock
// reading without an offset, like `time`.
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
];
}
+201
View File
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Command\CleanupUsersCommand;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class CleanupUsersCommandTest extends TestCase
{
public function testDeletesAccountsWithoutAnEffectiveRole(): void
{
$customer = $this->user('[email protected]', [Role::CUSTOMER]);
$roleless = $this->user('[email protected]', []);
$entityManager = $this->createMock(EntityManagerInterface::class);
$removed = [];
$entityManager->expects(self::exactly(2))
->method('remove')
->willReturnCallback(static function (User $user) use (&$removed): void {
$removed[] = $user->getEmail();
});
$entityManager->expects(self::once())->method('flush');
$tester = new CommandTester($this->command([$customer, $roleless], $entityManager));
$tester->execute(['--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertSame(['[email protected]', '[email protected]'], $removed);
self::assertStringContainsString('Deleted 2 account(s).', $tester->getDisplay());
}
/**
* The candidate query cannot read the roles, so everything the role policy protects has to
* survive this filter — including an account whose only effective role sits beside the
* ROLE_CUSTOMER fallback that older rows still carry.
*
* @param string[] $roles
*/
#[DataProvider('protectedRoleSets')]
public function testKeepsAccountsHoldingAnythingBeyondTheCustomerFallback(array $roles): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$tester = new CommandTester($this->command([$this->user('[email protected]', $roles)], $entityManager));
$tester->execute(['--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('No accounts to remove.', $tester->getDisplay());
}
/**
* @return iterable<string, array{string[]}>
*/
public static function protectedRoleSets(): iterable
{
yield 'teamer' => [[Role::TEAMER]];
yield 'employee' => [[Role::EMPLOYEE]];
yield 'approved administrative role' => [[Role::TEAMER, Role::MANAGER]];
yield 'house manager' => [[Role::HOUSE_MANAGER]];
yield 'pending nomination awaiting approval' => [[Role::CUSTOMER, Role::pending(Role::ADMIN)]];
yield 'stale fallback beside a real role' => [[Role::CUSTOMER, Role::TEAMER]];
}
/**
* The whole point of the guard: a bare invocation is a preview, not a deletion. Symfony's
* VALUE_REQUIRED only says an option needs a value when it is given, so nothing in the
* definition ever forces the caller to state an intent — this does.
*/
public function testRefusesToDeleteWithoutForce(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$entityManager->expects(self::never())->method('flush');
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
$tester->execute([]);
self::assertSame(Command::INVALID, $tester->getStatusCode());
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
self::assertStringContainsString('Refusing to delete without --force', $tester->getDisplay());
}
public function testDryRunDeletesNothing(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('remove');
$entityManager->expects(self::never())->method('flush');
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
$tester->execute(['--dry-run' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
self::assertStringNotContainsString('Refusing to delete', $tester->getDisplay());
self::assertStringContainsString('[email protected]', $tester->getDisplay());
}
/**
* --limit caps the deletions, not the candidates examined, so a run that finds one deletable
* account among protected ones still deletes it rather than spending the budget on rows it
* then discards.
*/
public function testLimitCountsDeletionsRatherThanCandidates(): void
{
$users = [
$this->user('[email protected]', [Role::TEAMER]),
$this->user('[email protected]', [Role::CUSTOMER]),
];
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())
->method('remove')
->with(self::callback(static fn (User $user): bool => '[email protected]' === $user->getEmail()));
$tester = new CommandTester($this->command($users, $entityManager));
$tester->execute(['--limit' => 1, '--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
self::assertStringContainsString('Deleted 1 account(s).', $tester->getDisplay());
}
public function testInactivityWindowIsOffUnlessAskedFor(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::once())
->method('findCleanupCandidates')
->with(null)
->willReturn([]);
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
$tester->execute(['--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testInactivityWindowIsPassedOnAsADate(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::once())
->method('findCleanupCandidates')
->with(self::callback(static function (?\DateTimeImmutable $since): bool {
$expected = new \DateTimeImmutable('-6 months');
return null !== $since && $since->format('Y-m-d') === $expected->format('Y-m-d');
}))
->willReturn([]);
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
$tester->execute(['--inactive-for' => 6, '--force' => true]);
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
}
public function testRejectsANonPositiveWindow(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::never())->method('findCleanupCandidates');
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
$tester->execute(['--inactive-for' => 0]);
self::assertSame(Command::INVALID, $tester->getStatusCode());
}
/**
* @param string[] $roles
*/
private function user(string $email, array $roles): User
{
return (new User($email))
->setRoles($roles)
->setLastLoginAt(new \DateTimeImmutable('-2 years'));
}
/**
* @param User[] $candidates
*/
private function command(array $candidates, EntityManagerInterface $entityManager): CleanupUsersCommand
{
$repository = $this->createStub(UserRepository::class);
$repository->method('findCleanupCandidates')->willReturn($candidates);
return $this->commandWith($repository, $entityManager);
}
private function commandWith(UserRepository $repository, EntityManagerInterface $entityManager): CleanupUsersCommand
{
return new CleanupUsersCommand($repository, $entityManager, $this->createStub(LoggerInterface::class));
}
}
+74 -16
View File
@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ExportController;
use App\Model\TravelExport;
use App\Model\TravelExportQuery;
use App\Model\Export;
use App\Model\ExportQuery;
use App\Service\ProductExportBuilder;
use App\Service\TravelExportBuilder;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
@@ -16,7 +17,7 @@ class ExportControllerTest extends TestCase
{
public function testPaginatesAndReportsWholeExportFigures(): void
{
$meta = $this->call(new TravelExportQuery(1, 5))['meta'];
$meta = $this->call(new ExportQuery(1, 5))['meta'];
self::assertSame(1, $meta['page']);
self::assertSame(5, $meta['perPage']);
@@ -27,18 +28,18 @@ class ExportControllerTest extends TestCase
public function testCountersAreIdenticalOnEveryPage(): void
{
// Whole-export figures repeated per page, so a consumer can abort after page 1.
$first = $this->call(new TravelExportQuery(1, 5))['meta'];
$last = $this->call(new TravelExportQuery(3, 5))['meta'];
$first = $this->call(new ExportQuery(1, 5))['meta'];
$last = $this->call(new ExportQuery(3, 5))['meta'];
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'] as $key) {
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'sourceGeneratedAt', 'generatedAt'] as $key) {
self::assertSame($first[$key], $last[$key], $key.' must not vary by page');
}
}
public function testConsecutivePagesDoNotOverlap(): void
{
$first = array_column($this->call(new TravelExportQuery(1, 5))['data'], 'busProId');
$second = array_column($this->call(new TravelExportQuery(2, 5))['data'], 'busProId');
$first = array_column($this->call(new ExportQuery(1, 5))['data'], 'busProId');
$second = array_column($this->call(new ExportQuery(2, 5))['data'], 'busProId');
self::assertSame([1, 2, 3, 4, 5], $first);
self::assertSame([6, 7, 8, 9, 10], $second);
@@ -47,7 +48,7 @@ class ExportControllerTest extends TestCase
public function testTheLastPageIsShort(): void
{
$data = $this->call(new TravelExportQuery(3, 5))['data'];
$data = $this->call(new ExportQuery(3, 5))['data'];
self::assertCount(2, $data);
self::assertSame([11, 12], array_column($data, 'busProId'));
@@ -55,7 +56,7 @@ class ExportControllerTest extends TestCase
public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void
{
$body = $this->call(new TravelExportQuery(99, 5));
$body = $this->call(new ExportQuery(99, 5));
self::assertSame([], $body['data']);
self::assertSame(12, $body['meta']['total']);
@@ -78,6 +79,18 @@ class ExportControllerTest extends TestCase
self::assertSame(7, $meta['skipped']);
self::assertSame(21, $meta['pickupsWithoutMasterData']);
self::assertSame('2026-09-29T03:20:00+02:00', $meta['generatedAt']);
self::assertSame('2026-09-28T23:10:15+02:00', $meta['sourceGeneratedAt']);
}
public function testAnUnknownSourceExportIsNullRatherThanOmitted(): void
{
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willReturn(new Export([], 0, 0, new \DateTimeImmutable()));
$meta = json_decode((string) $this->controller($builder)->travels(new ExportQuery())->getContent(), true)['meta'];
self::assertArrayHasKey('sourceGeneratedAt', $meta);
self::assertNull($meta['sourceGeneratedAt']);
}
/**
@@ -91,13 +104,57 @@ class ExportControllerTest extends TestCase
$this->expectException(\RuntimeException::class);
$this->controller($builder)->travels(new TravelExportQuery());
$this->controller($builder)->travels(new ExportQuery());
}
public function testProductsUseTheSameEnvelopeAndPaging(): void
{
$rows = [];
for ($i = 1; $i <= 3; ++$i) {
$rows[] = ['date' => ['busProId' => $i], 'hotel' => ['busProId' => 1]];
}
$productBuilder = $this->createStub(ProductExportBuilder::class);
$productBuilder->method('build')->willReturn(new Export(
$rows,
2,
5,
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
new \DateTimeImmutable('2026-09-28 23:10:15', new \DateTimeZone('Europe/Berlin')),
));
$response = $this->controller($this->createStub(TravelExportBuilder::class), $productBuilder)
->products(new ExportQuery(2, 2));
$body = json_decode((string) $response->getContent(), true);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame([3], array_column(array_column($body['data'], 'date'), 'busProId'));
self::assertSame([
'page' => 2,
'perPage' => 2,
'total' => 3,
'totalPages' => 2,
'skipped' => 2,
'pickupsWithoutMasterData' => 5,
'sourceGeneratedAt' => '2026-09-28T23:10:15+02:00',
'generatedAt' => '2026-09-29T03:20:00+02:00',
], $body['meta']);
}
public function testAProductBuilderFailureIsNotDowngradedToATwoHundred(): void
{
$productBuilder = $this->createStub(ProductExportBuilder::class);
$productBuilder->method('build')->willThrowException(new \RuntimeException('cache exploded'));
$this->expectException(\RuntimeException::class);
$this->controller($this->createStub(TravelExportBuilder::class), $productBuilder)->products(new ExportQuery());
}
/**
* @return array<string, mixed>
*/
private function call(?TravelExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
private function call(?ExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
{
$rows = [];
for ($i = 1; $i <= 12; ++$i) {
@@ -105,23 +162,24 @@ class ExportControllerTest extends TestCase
}
$builder = $this->createStub(TravelExportBuilder::class);
$builder->method('build')->willReturn(new TravelExport(
$builder->method('build')->willReturn(new Export(
$rows,
$skipped,
$withoutMasterData,
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
new \DateTimeImmutable('2026-09-28 23:10:15', new \DateTimeZone('Europe/Berlin')),
));
$response = $this->controller($builder)->travels($query ?? new TravelExportQuery());
$response = $this->controller($builder)->travels($query ?? new ExportQuery());
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
return json_decode((string) $response->getContent(), true);
}
private function controller(TravelExportBuilder $builder): ExportController
private function controller(TravelExportBuilder $builder, ?ProductExportBuilder $productBuilder = null): ExportController
{
$controller = new ExportController($builder);
$controller = new ExportController($builder, $productBuilder ?? $this->createStub(ProductExportBuilder::class));
// No 'serializer' service registered, so AbstractController::json() falls back to
// JsonResponse -- which is what this endpoint produces in production anyway.
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\LastUpdateController;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
class LastUpdateControllerTest extends TestCase
{
public function testReturnsTheMarkerTimestamp(): void
{
self::assertSame('2026-09-22 11:10:15', $this->timestampFor(
"22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n"
));
}
public function testAMissingMarkerIsNullRatherThanAServerError(): void
{
self::assertNull($this->timestampFor(null));
}
public function testAnEmptyMarkerIsNull(): void
{
self::assertNull($this->timestampFor(''));
}
public function testAnUnparseableMarkerIsNull(): void
{
self::assertNull($this->timestampFor("gestern\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\n"));
}
private function timestampFor(?string $marker): ?string
{
$storage = $this->createStub(FilesystemOperator::class);
$storage->method('fileExists')->willReturn(null !== $marker);
$storage->method('read')->willReturn((string) $marker);
$controller = new LastUpdateController($storage);
$controller->setContainer(new Container());
$body = json_decode((string) $controller->index()->getContent(), true);
self::assertArrayHasKey('timestamp', $body);
return $body['timestamp'];
}
}
+98
View File
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<reisen>
<reise id="1" idbuspro="1127" code="DKIWE">
<!-- Two hotels on one date: one row per pair, date-level data shared, hotel-level data not. -->
<termin id="1" idbuspro="900" idprodukt="1127" termin="04.12.2026" bis="06.12.2026" code="DKIWE041226" reiseart="F">
<selektiongruppe id="1" idbuspro="1" bezeichnung="Reisezeitraum">
<selektion id="1" idbuspro="863" bezeichnung="Wintersportreise" />
</selektiongruppe>
<selektiongruppe id="2" idbuspro="7" bezeichnung="Leistungen Skipass">
<selektion id="1" idbuspro="92" bezeichnung="3 Tage Skipass" />
<selektion id="2" idbuspro="93" bezeichnung="Skipass Region" />
</selektiongruppe>
<selektiongruppe id="3" idbuspro="99" bezeichnung="Leistungen Personal" />
<text>Davos Klosters</text>
<lei_befoerderung>
<leistung id="1" idbuspro="193804" unterart="PKW">
<text>Eigene Anreise mit Rabatt</text>
<preis>-50,00</preis>
<status>Frei</status>
<richtung>HIN</richtung>
</leistung>
<!-- A return-leg bus with a guide: the guide must not be taken from it. -->
<leistung id="2" idbuspro="193811" unterart="BUS">
<text>Bus Rückfahrt</text>
<preis>0,00</preis>
<status>Buchungsstop</status>
<richtung>RUECK</richtung>
<zustiegsplanung>
<reiseleiter><name>Rück Leiter</name><telefon>000</telefon></reiseleiter>
</zustiegsplanung>
</leistung>
<leistung id="3" idbuspro="193802" unterart="BUS">
<text>Bus Hinfahrt</text>
<preis>0,00</preis>
<status>Frei</status>
<richtung>HIN</richtung>
<zustiegsplanung>
<reiseleiter><name>Hin Leiterin</name><telefon>0171 123</telefon></reiseleiter>
</zustiegsplanung>
</leistung>
<leistung id="4" idbuspro="193820" unterart="BAH">
<text>Zug</text>
<preis>1.234,50</preis>
<status>Anfrage</status>
<richtung>HIN</richtung>
</leistung>
</lei_befoerderung>
<lei_sonstiges>
<leistung id="1" idbuspro="193805" unterart="SON">
<text>Keycard-Pfand (Pflichtabgabe)</text>
<preis>5,40</preis>
<status>Frei</status>
</leistung>
<leistung id="2" idbuspro="193830" unterart="EIN">
<text>Einzelzimmerzuschlag</text>
<status>Frei</status>
</leistung>
</lei_sonstiges>
<hotel id="1" code="DKIWEKT" idbuspro="222931">
<!-- Same per-list id as the date-level Keycard entry, different service. -->
<lei_sonstiges>
<leistung id="1" idbuspro="193840" unterart="SPA">
<text>Skipass Hotel</text>
<preis>60,00</preis>
<status>Frei</status>
</leistung>
</lei_sonstiges>
<zimmer>
<preis zimmercode="MBZET " idbuspro_zimmer="74" zimmertext="Bett im Mehrbettzimmer" MinPax="1" MaxPax="1" naechte="2" kat="GR" vpcode="Lt. Auss." idbuspro_vp="2" preis="229,50" status="Frei" verfuegbar="24" />
<preis zimmercode="PDGS" idbuspro_zimmer="999" zimmertext="Pseudo" MinPax="1" MaxPax="1" naechte="2" preis="299,00" status="Frei" verfuegbar="0" />
<preis zimmercode="3erET" idbuspro_zimmer="98" zimmertext="3er Zimmer" MinPax="3" MaxPax="3" naechte="2" kat="GR" vpcode="HP" idbuspro_vp="3" preis="259,00" status="Buchungsstop" verfuegbar="0" />
</zimmer>
</hotel>
<hotel id="2" code="DKIWEK2" idbuspro="222932">
<zimmer>
<preis zimmercode="DZ" idbuspro_zimmer="11" zimmertext="Doppelzimmer" MinPax="2" MaxPax="2" naechte="2" kat="DZ" vpcode="HP" idbuspro_vp="3" preis="" status="Anfrage" verfuegbar="" />
</zimmer>
</hotel>
<zustiege>
<zustieg id="1" idbuspro="4" zeit="03.12.2026 23:30:00" preis="5,90" />
<!-- Bare time after the 23:30 above: rolls onto the next calendar day. -->
<zustieg id="2" idbuspro="27" zeit="01:45" />
<!-- Referenced but absent from zustiege.xml. -->
<zustieg id="3" idbuspro="55" zeit="03.12.2026 21:00:00" preis="0,00" />
<!-- No id: the consumer drops it, not the export. -->
<zustieg id="4" idbuspro="0" zeit="03.12.2026 20:00:00" />
</zustiege>
<zustiege_rueck>
<!-- Priced on the return leg only: levelling would copy 3,00 onto the outbound entry. -->
<zustieg_rueck id="1" idbuspro="27" preis="3,00" />
</zustiege_rueck>
</termin>
<!-- Unparseable date: its hotel row is dropped and counted. -->
<termin id="2" idbuspro="901" termin="kaputt" bis="06.12.2026" code="DKIWE000000">
<hotel id="1" code="DKIWEKT" idbuspro="222931" />
</termin>
</reise>
</reisen>
+299
View File
@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Pickup;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\ProductExportParser;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\Export;
use App\Service\ProductExportBuilder;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FileAttributes;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
class ProductExportBuilderTest extends TestCase
{
public function testEmitsOneRowPerTravelDateAndHotelPair(): void
{
$export = $this->build();
$keys = array_map(
static fn (array $row): string => $row['date']['busProId'].'-'.$row['hotel']['busProId'],
$export->rows,
);
self::assertSame(['900-222931', '900-222932'], $keys);
}
public function testCarriesIdentityFromTheTravelXmlAlone(): void
{
$row = $this->rowFor('900-222931');
self::assertSame(['code' => 'DKIWE', 'busProId' => 1127], $row['product']);
self::assertSame([
'code' => 'DKIWE041226',
'busProId' => 900,
'from' => '2026-12-04',
'to' => '2026-12-06',
'type' => 'F',
], $row['date']);
self::assertSame(['code' => 'DKIWEKT', 'busProId' => 222931], $row['hotel']);
}
public function testDropsAndCountsRowsOfAnUnparseableDate(): void
{
self::assertSame(1, $this->build()->skipped);
}
public function testEmitsEveryRoomButThePseudoRoomVerbatim(): void
{
$rooms = $this->rowFor('900-222931')['rooms'];
self::assertSame(['MBZET', '3erET'], array_column($rooms, 'code'), 'PDGS is not a room; the code is trimmed');
self::assertSame([
'busProId' => 74,
'code' => 'MBZET',
'label' => 'Bett im Mehrbettzimmer',
'category' => 'GR',
'minPax' => 1,
'maxPax' => 1,
'nights' => 2,
'price' => 229.5,
'available' => 24,
'status' => 'Frei',
'boardCode' => 'Lt. Auss.',
'boardBusProId' => 2,
], $rooms[0]);
// Buchungsstop rooms reach the consumer: "ausgebucht" is its call.
self::assertSame('Buchungsstop', $rooms[1]['status']);
self::assertSame(3, $rooms[1]['maxPax'], 'MaxPax is read despite its casing');
}
public function testAMissingRoomPriceOrAvailabilityIsNullNotZero(): void
{
$room = $this->rowFor('900-222932')['rooms'][0];
self::assertNull($room['price']);
self::assertNull($room['available']);
}
public function testThePseudoRoomBecomesThePseudoPriceOfItsHotelOnly(): void
{
self::assertSame(299.0, $this->rowFor('900-222931')['pseudoPrice']);
self::assertNull($this->rowFor('900-222932')['pseudoPrice']);
}
public function testKeepsTheThreeServiceListsApartAndUnfiltered(): void
{
$first = $this->rowFor('900-222931');
$second = $this->rowFor('900-222932');
self::assertSame([193804, 193811, 193802, 193820], array_column($first['transportServices'], 'busProId'), 'RUECK and Buchungsstop are kept');
self::assertSame([193805, 193830], array_column($first['dateServices'], 'busProId'), 'EIN is kept; hotel services do not leak in');
self::assertSame([193840], array_column($first['hotelServices'], 'busProId'));
self::assertSame([], $second['hotelServices'], 'a hotel without its own list gets an empty one, not the date-level one');
self::assertSame($first['dateServices'], $second['dateServices']);
}
public function testServicesAreNumericAndNullWhereAbsent(): void
{
$row = $this->rowFor('900-222931');
self::assertSame([
'busProId' => 193804,
'subType' => 'PKW',
'label' => 'Eigene Anreise mit Rabatt',
'price' => -50.0,
'status' => 'Frei',
'direction' => 'HIN',
], $row['transportServices'][0]);
self::assertSame(1234.5, $row['transportServices'][3]['price'], 'thousands separator');
self::assertNull($row['dateServices'][0]['direction'], 'general services have no direction');
self::assertNull($row['dateServices'][1]['price'], 'a missing price is null, not 0');
}
public function testEmitsEverySelectionGroupInSourceOrder(): void
{
$groups = $this->rowFor('900-222931')['selectionGroups'];
self::assertSame([1, 7, 99], array_column($groups, 'busProId'));
self::assertSame('Leistungen Skipass', $groups[1]['label']);
self::assertSame([
['busProId' => 92, 'label' => '3 Tage Skipass'],
['busProId' => 93, 'label' => 'Skipass Region'],
], $groups[1]['selections']);
self::assertSame([], $groups[2]['selections']);
}
public function testTakesTheGuideFromTheOutboundBus(): void
{
self::assertSame(['name' => 'Hin Leiterin', 'phone' => '0171 123'], $this->rowFor('900-222931')['guide']);
}
public function testTheGuideIsNullWithoutAnOutboundOne(): void
{
$xml = str_replace('<name>Hin Leiterin</name>', '', (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml'));
$xml = preg_replace('#<leistung id="3" idbuspro="193802".*?</leistung>#s', '', $xml);
self::assertNull($this->build((string) $xml)->rows[0]['guide']);
}
public function testEmitsPickupsVerbatimWithResolvedDatetimes(): void
{
$pickups = $this->rowFor('900-222931')['pickups'];
self::assertSame([4, 27, 55, 0], array_column($pickups, 'busProId'), 'source order, id 0 kept');
self::assertSame([
'busProId' => 4,
'code' => 'Z4',
'city' => 'Stadt 4',
'street' => 'Strasse 4',
'price' => 5.9,
'datetime' => '2026-12-03T23:30:00',
], $pickups[0]);
self::assertNull($pickups[1]['price'], 'an absent price is null');
self::assertSame('2026-12-04T01:45:00', $pickups[1]['datetime']);
self::assertSame(0.0, $pickups[2]['price'], 'a stated 0,00 is kept as 0');
self::assertNull($pickups[2]['code']);
}
public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void
{
// DPWUSMP280327 in the 30.09.2026 13:11 export. Berlin skips 02:00-02:59 that night.
$pickups = $this->pickupsFor('28.03.2027', <<<'XML'
<zustieg id="1" idbuspro="4" zeit="00:30" />
<zustieg id="2" idbuspro="27" zeit="02:45" />
<zustieg id="3" idbuspro="3" zeit="27.03.2027 22:00:00" />
<zustieg id="4" idbuspro="89" zeit="01:30" />
<zustieg id="5" idbuspro="86" zeit="27.03.2027 23:30:00" />
<zustieg id="6" idbuspro="74" zeit="03:45" />
<zustieg id="7" idbuspro="262" zeit="04:45" />
XML);
self::assertSame([
'2027-03-28T00:30:00',
'2027-03-28T02:45:00',
'2027-03-27T22:00:00',
'2027-03-28T01:30:00',
'2027-03-27T23:30:00',
'2027-03-28T03:45:00',
'2027-03-28T04:45:00',
], array_column($pickups, 'datetime'));
}
public function testPickupTimesInTheFallBackHourPassThroughWhenRolledOntoTheNextDay(): void
{
// Berlin runs 02:00-02:59 twice on 31.10.2027; the bare times roll onto it from the 30th.
$pickups = $this->pickupsFor('30.10.2027', <<<'XML'
<zustieg id="1" idbuspro="4" zeit="30.10.2027 23:30:00" />
<zustieg id="2" idbuspro="27" zeit="02:15" />
<zustieg id="3" idbuspro="55" zeit="02:59" />
<zustieg id="4" idbuspro="0" zeit="31.10.2027 02:30:00" />
XML);
self::assertSame([
'2027-10-30T23:30:00',
'2027-10-31T02:15:00',
'2027-10-31T02:59:00',
'2027-10-31T02:30:00',
], array_column($pickups, 'datetime'));
}
public function testDoesNotLevelBoardingPointPrices(): void
{
// Point 27 is priced on the return leg only. The booking path levels that onto the
// outbound entry; the website publishes the outbound price as exported.
self::assertNull($this->rowFor('900-222931')['pickups'][1]['price']);
}
public function testCountsPickupsWithoutMasterDataButNotTheIdlessOne(): void
{
self::assertSame(1, $this->build()->pickupsWithoutMasterData);
}
public function testCarriesTheSourceExportTimestamp(): void
{
$export = $this->build(null, "22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n");
self::assertSame('2026-09-22T11:10:15+02:00', $export->sourceGeneratedAt?->format('c'));
}
/**
* @return array<string, mixed>
*/
private function rowFor(string $key): array
{
foreach ($this->build()->rows as $row) {
if ($row['date']['busProId'].'-'.$row['hotel']['busProId'] === $key) {
return $row;
}
}
self::fail('No row for '.$key);
}
/**
* The fixture's first date moved to $dateFrom, with $zustiege as its boarding list.
*
* @return list<array<string, mixed>>
*/
private function pickupsFor(string $dateFrom, string $zustiege): array
{
$xml = (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml');
$xml = str_replace('termin="04.12.2026"', 'termin="'.$dateFrom.'"', $xml);
$xml = (string) preg_replace('#<zustiege>.*?</zustiege>#s', '<zustiege>'.$zustiege.'</zustiege>', $xml);
return $this->build($xml)->rows[0]['pickups'];
}
private function build(?string $xml = null, ?string $marker = null): Export
{
$xml ??= (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml');
$filesystem = $this->createStub(FilesystemOperator::class);
$filesystem->method('listContents')->willReturn(new DirectoryListing([new FileAttributes('Ziel_1127.xml')]));
$filesystem->method('read')->willReturnCallback(
static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $xml
);
$filesystem->method('fileExists')->willReturnCallback(
static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker
);
$pickupLoader = $this->createStub(PickupLoader::class);
$pickupLoader->method('loadAll')->willReturn([
4 => $this->pickup(4),
27 => $this->pickup(27),
]);
return (new ProductExportBuilder(
$pickupLoader,
// Levelling on, to prove the export does not go through it.
new TravelParser(new AgeConstraintParserRegistry([]), true),
new ProductExportParser(),
$filesystem,
new TagAwareAdapter(new ArrayAdapter()),
new NullLogger(),
))->build();
}
private function pickup(int $id): Pickup
{
$pickup = new Pickup();
$pickup->id = $id;
$pickup->code = 'Z'.$id;
$pickup->city = 'Stadt '.$id;
$pickup->street = 'Strasse '.$id;
return $pickup;
}
}
+55 -9
View File
@@ -10,7 +10,7 @@ use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
use App\BusProNet\XmlParser\TravelParser;
use App\Model\TravelExport;
use App\Model\Export;
use App\Service\TravelExportBuilder;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FileAttributes;
@@ -61,26 +61,57 @@ class TravelExportBuilderTest extends TestCase
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
self::assertSame('23:59', $pickups[10]['time']);
self::assertSame('2026-12-26T23:59:00+01:00', $pickups[10]['datetime']);
self::assertSame('2026-12-26T23:59:00', $pickups[10]['datetime']);
// Time only, so it defaults to the departure date -- which would put it before the
// 23:59 above. The coach cannot board backwards, so it rolls onto the next day.
self::assertSame('02:15', $pickups[11]['time']);
self::assertSame('2026-12-27T02:15:00+01:00', $pickups[11]['datetime']);
self::assertSame('2026-12-27T02:15:00', $pickups[11]['datetime']);
}
public function testEmitsTheSummerOffset(): void
public function testEmitsNoOffsetInSummerEither(): void
{
$pickups = $this->rowFor('901-1')['pickups'];
self::assertSame('2027-07-15T08:00:00+02:00', $pickups[0]['datetime']);
self::assertSame('2027-07-15T08:00:00', $pickups[0]['datetime']);
}
public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void
{
// Berlin skips 02:00-02:59 on 28.03.2027; BusPro still plans a stop at 02:45.
$pickups = $this->build(<<<'XML'
<?xml version="1.0"?>
<reisen><reise><termin idbuspro="1" termin="28.03.2027" bis="03.04.2027"><hotel idbuspro="1"/><zustiege>
<zustieg idbuspro="10" zeit="27.03.2027 23:30:00"/>
<zustieg idbuspro="11" zeit="02:45"/>
<zustieg idbuspro="12" zeit="03:45"/>
</zustiege></termin></reise></reisen>
XML)->rows[0]['pickups'];
self::assertSame(['23:30', '02:45', '03:45'], array_column($pickups, 'time'));
self::assertSame(['2027-03-27T23:30:00', '2027-03-28T02:45:00', '2027-03-28T03:45:00'], array_column($pickups, 'datetime'));
}
public function testPickupTimesInTheFallBackHourPassThroughWhenRolledOntoTheNextDay(): void
{
// Berlin runs 02:00-02:59 twice on 31.10.2027; the bare times roll onto it from the 30th.
$pickups = $this->build(<<<'XML'
<?xml version="1.0"?>
<reisen><reise><termin idbuspro="1" termin="30.10.2027" bis="06.11.2027"><hotel idbuspro="1"/><zustiege>
<zustieg idbuspro="10" zeit="30.10.2027 23:30:00"/>
<zustieg idbuspro="11" zeit="02:15"/>
<zustieg idbuspro="12" zeit="02:59"/>
</zustiege></termin></reise></reisen>
XML)->rows[0]['pickups'];
self::assertSame(['2027-10-30T23:30:00', '2027-10-31T02:15:00', '2027-10-31T02:59:00'], array_column($pickups, 'datetime'));
}
public function testATimeOnlyPickupThatAlreadyFollowsTheLastDatedOneKeepsTheDepartureDate(): void
{
$pickups = array_column($this->rowFor('901-1')['pickups'], null, 'busProId');
self::assertSame('2027-07-15T09:30:00+02:00', $pickups[11]['datetime'], 'no roll when the order already holds');
self::assertSame('2027-07-15T09:30:00', $pickups[11]['datetime'], 'no roll when the order already holds');
}
public function testKeepsAPickupWithoutAZeitAndNullsBothTimeFields(): void
@@ -104,7 +135,7 @@ class TravelExportBuilderTest extends TestCase
self::assertNull($pickups[99]['city']);
self::assertNull($pickups[99]['street']);
self::assertSame('21:00', $pickups[99]['time'], 'the time is from the travel XML, not master data');
self::assertSame('2026-12-26T21:00:00+01:00', $pickups[99]['datetime']);
self::assertSame('2026-12-26T21:00:00', $pickups[99]['datetime']);
// Counted once, not once per row that references it.
self::assertSame(1, $export->pickupsWithoutMasterData);
@@ -156,6 +187,18 @@ class TravelExportBuilderTest extends TestCase
/**
* @return array<string, mixed>
*/
public function testCarriesTheSourceExportTimestamp(): void
{
$export = $this->build(null, ['Ziel_1.xml'], "22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n");
self::assertSame('2026-09-22T11:10:15+02:00', $export->sourceGeneratedAt?->format('c'));
}
public function testTheSourceExportTimestampIsNullWithoutAMarker(): void
{
self::assertNull($this->build()->sourceGeneratedAt);
}
private function rowFor(string $key): array
{
foreach ($this->build()->rows as $row) {
@@ -170,7 +213,7 @@ class TravelExportBuilderTest extends TestCase
/**
* @param list<string> $files
*/
private function build(?string $xml = null, array $files = ['Ziel_1.xml']): TravelExport
private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): Export
{
$contents = [
'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'),
@@ -183,7 +226,10 @@ class TravelExportBuilderTest extends TestCase
$files,
)));
$filesystem->method('read')->willReturnCallback(
static fn (string $path): string => $contents[$path] ?? ''
static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $contents[$path] ?? ''
);
$filesystem->method('fileExists')->willReturnCallback(
static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker
);
$hotelLoader = $this->createStub(HotelLoader::class);