feat: add paginated travel export endpoint for the teamer app
GET /api/travels/export flattens the XML export into one row per travel
date x hotel with its pickups resolved, so the Teamer app can import the
disposition in one walk instead of fanning out over /api/travels.
Built from the XML file map, not travel_snapshot: the snapshot table sits
at this grain but refreshes on its own schedule, while the loaders share
the bpn.cache pool under the xml-sync tag. That is what makes /api/hotels,
/api/pickups and this export move together for free.
Each Ziel_*.xml is walked once and TravelParser::getPickups() does the
zeit parsing, which buys both zeit shapes and adjustOvernightTimes() --
a time-only 02:15 listed after a 23:59 rolls onto the next calendar day.
Pickups join against PickupLoader::loadAll() rather than
patchPickupsDetails(), which sorts: the contract is source order.
The whole assembled export is cached, not a page, so skipped, total and
pickupsWithoutMasterData are whole-export figures identical on every page
and a consumer can abort after the first one. Errors stay real status
codes; the builder is deliberately not wrapped in a catch that would
answer 200 with an error body the way hotelAvailability() does.
The route is declared before api_travel_single_code and that route grows
a requirement, or /api/travels/export resolves to byCode('export') and
404s as though the endpoint existed with no data.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
0a5602fd41
commit
c5685e6d7c
9 files changed
+774
No files matched your search
@@ -0,0 +1,138 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user