GET /api/travels/export flattens the XML export into one row per travel
date x hotel with its pickups resolved, so the Teamer app can import the
disposition in one walk instead of fanning out over /api/travels.
Built from the XML file map, not travel_snapshot: the snapshot table sits
at this grain but refreshes on its own schedule, while the loaders share
the bpn.cache pool under the xml-sync tag. That is what makes /api/hotels,
/api/pickups and this export move together for free.
Each Ziel_*.xml is walked once and TravelParser::getPickups() does the
zeit parsing, which buys both zeit shapes and adjustOvernightTimes() --
a time-only 02:15 listed after a 23:59 rolls onto the next calendar day.
Pickups join against PickupLoader::loadAll() rather than
patchPickupsDetails(), which sorts: the contract is source order.
The whole assembled export is cached, not a page, so skipped, total and
pickupsWithoutMasterData are whole-export figures identical on every page
and a consumer can abort after the first one. Errors stay real status
codes; the builder is deliberately not wrapped in a catch that would
answer 200 with an error body the way hotelAvailability() does.
The route is declared before api_travel_single_code and that route grows
a requirement, or /api/travels/export resolves to byCode('export') and
404s as though the endpoint existed with no data.
Co-Authored-By: Claude Opus 5 <[email protected]>
139 lines
4.8 KiB
PHP
139 lines
4.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Controller\Api;
|
|
|
|
use App\BusProNet\ApiClient;
|
|
use App\Controller\Api\TravelController;
|
|
use App\Model\TravelExport;
|
|
use App\Model\TravelExportQuery;
|
|
use App\Service\TravelDataProvider;
|
|
use App\Service\TravelExportBuilder;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\DependencyInjection\Container;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class TravelExportControllerTest extends TestCase
|
|
{
|
|
public function testPaginatesAndReportsWholeExportFigures(): void
|
|
{
|
|
$meta = $this->call(new TravelExportQuery(1, 5))['meta'];
|
|
|
|
self::assertSame(1, $meta['page']);
|
|
self::assertSame(5, $meta['perPage']);
|
|
self::assertSame(12, $meta['total'], 'total is the whole export, not this page');
|
|
self::assertSame(3, $meta['totalPages']);
|
|
}
|
|
|
|
public function testCountersAreIdenticalOnEveryPage(): void
|
|
{
|
|
// Whole-export figures repeated per page, so a consumer can abort after page 1.
|
|
$first = $this->call(new TravelExportQuery(1, 5))['meta'];
|
|
$last = $this->call(new TravelExportQuery(3, 5))['meta'];
|
|
|
|
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'] as $key) {
|
|
self::assertSame($first[$key], $last[$key], $key.' must not vary by page');
|
|
}
|
|
}
|
|
|
|
public function testConsecutivePagesDoNotOverlap(): void
|
|
{
|
|
$first = array_column($this->call(new TravelExportQuery(1, 5))['data'], 'busProId');
|
|
$second = array_column($this->call(new TravelExportQuery(2, 5))['data'], 'busProId');
|
|
|
|
self::assertSame([1, 2, 3, 4, 5], $first);
|
|
self::assertSame([6, 7, 8, 9, 10], $second);
|
|
self::assertSame([], array_intersect($first, $second));
|
|
}
|
|
|
|
public function testTheLastPageIsShort(): void
|
|
{
|
|
$data = $this->call(new TravelExportQuery(3, 5))['data'];
|
|
|
|
self::assertCount(2, $data);
|
|
self::assertSame([11, 12], array_column($data, 'busProId'));
|
|
}
|
|
|
|
public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void
|
|
{
|
|
$body = $this->call(new TravelExportQuery(99, 5));
|
|
|
|
self::assertSame([], $body['data']);
|
|
self::assertSame(12, $body['meta']['total']);
|
|
}
|
|
|
|
public function testDefaultsToTheFirstPageOfFiveHundred(): void
|
|
{
|
|
$body = $this->call();
|
|
|
|
self::assertSame(1, $body['meta']['page']);
|
|
self::assertSame(500, $body['meta']['perPage']);
|
|
self::assertSame(1, $body['meta']['totalPages']);
|
|
self::assertCount(12, $body['data']);
|
|
}
|
|
|
|
public function testCarriesTheBuilderCounters(): void
|
|
{
|
|
$meta = $this->call(null, 7, 21)['meta'];
|
|
|
|
self::assertSame(7, $meta['skipped']);
|
|
self::assertSame(21, $meta['pickupsWithoutMasterData']);
|
|
self::assertSame('2026-09-29T03:20:00+02:00', $meta['generatedAt']);
|
|
}
|
|
|
|
/**
|
|
* A builder failure must reach the consumer as a 5xx, never as the 200-with-an-error-body
|
|
* shape hotelAvailability() uses: the consumer's retry logic keys on the status code.
|
|
*/
|
|
public function testABuilderFailureIsNotDowngradedToATwoHundred(): void
|
|
{
|
|
$builder = $this->createStub(TravelExportBuilder::class);
|
|
$builder->method('build')->willThrowException(new \RuntimeException('cache exploded'));
|
|
|
|
$this->expectException(\RuntimeException::class);
|
|
|
|
$this->controller($builder)->export(new TravelExportQuery());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function call(?TravelExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
|
|
{
|
|
$rows = [];
|
|
for ($i = 1; $i <= 12; ++$i) {
|
|
$rows[] = ['busProId' => $i, 'hotel' => ['busProId' => 1], 'pickups' => []];
|
|
}
|
|
|
|
$builder = $this->createStub(TravelExportBuilder::class);
|
|
$builder->method('build')->willReturn(new TravelExport(
|
|
$rows,
|
|
$skipped,
|
|
$withoutMasterData,
|
|
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
|
|
));
|
|
|
|
$response = $this->controller($builder)->export($query ?? new TravelExportQuery());
|
|
|
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
|
|
|
return json_decode((string) $response->getContent(), true);
|
|
}
|
|
|
|
private function controller(TravelExportBuilder $builder): TravelController
|
|
{
|
|
$controller = new TravelController(
|
|
$this->createStub(ApiClient::class),
|
|
$this->createStub(TravelDataProvider::class),
|
|
$builder,
|
|
);
|
|
|
|
// No 'serializer' service registered, so AbstractController::json() falls back to
|
|
// JsonResponse -- which is what this endpoint produces in production anyway.
|
|
$controller->setContainer(new Container());
|
|
|
|
return $controller;
|
|
}
|
|
}
|