feat: api endpoints and xml sync for contingents data

This commit is contained in:
Björn Fromme
2026-04-22 11:23:52 +02:00
parent a1f68ec11d
commit 9b84893d88
50 changed files with 2567 additions and 221 deletions
@@ -7,6 +7,7 @@ use App\BusProNet\XmlLoader\HotelLoader;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
class HotelDataLoaderTest extends TestCase
{
@@ -32,7 +33,7 @@ class HotelDataLoaderTest extends TestCase
->with('hotel.xml')
->willReturn($xmlContent);
$cache = new ArrayAdapter();
$cache = new TagAwareAdapter(new ArrayAdapter());
$loader = new HotelLoader($cache, $filesystem);
$hotels = $loader->loadAll();
@@ -7,6 +7,7 @@ use App\BusProNet\XmlLoader\PickupLoader;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
class PickupDataLoaderTest extends TestCase
{
@@ -32,7 +33,7 @@ class PickupDataLoaderTest extends TestCase
->with('zustiege.xml')
->willReturn($xmlContent);
$cache = new ArrayAdapter();
$cache = new TagAwareAdapter(new ArrayAdapter());
$loader = new PickupLoader($cache, $filesystem);
$pickup = $loader->loadById(1);
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace BusProNet\Utility;
use App\BusProNet\Utility\BookingUrlUtility;
use PHPUnit\Framework\TestCase;
class BookingUrlUtilityTest extends TestCase
{
public function testBuildUsesConfiguredDefaultBaseUrlAsAbsoluteFallback(): void
{
$utility = new BookingUrlUtility('https://my.ep-reisen.de');
$url = $utility->build(157047, 11606);
$this->assertSame(
'https://my.ep-reisen.de/bookings/create?date_id=11606&hotel_id=157047',
$url
);
}
public function testBuildUsesCustomBaseUrlWhenProvided(): void
{
$utility = new BookingUrlUtility('https://my.ep-reisen.de');
$url = $utility->build(157047, 11606, 'https://my.example.test/');
$this->assertSame(
'https://my.example.test/bookings/create?date_id=11606&hotel_id=157047',
$url
);
}
public function testBuildFallsBackToConfiguredDefaultWhenCustomBaseIsBlank(): void
{
$utility = new BookingUrlUtility('https://fallback.test');
$url = $utility->build(157047, 11606, ' ');
$this->assertSame(
'https://fallback.test/bookings/create?date_id=11606&hotel_id=157047',
$url
);
}
public function testBuildReturnsRelativeUrlWhenNoBaseUrlAvailable(): void
{
$utility = new BookingUrlUtility('');
$url = $utility->build(157047, 11606);
$this->assertSame('/bookings/create?date_id=11606&hotel_id=157047', $url);
}
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlLoader;
use App\BusProNet\XmlLoader\ContingentLoader;
use App\BusProNet\XmlParser\ContingentParser;
use League\Flysystem\DirectoryListing;
use League\Flysystem\FileAttributes;
use League\Flysystem\FilesystemOperator;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class ContingentLoaderTest extends TestCase
{
public function testGenerateFilesMapBuildsMappingAndTagsCache(): void
{
$cache = $this->createMock(TagAwareCacheInterface::class);
$filesystem = $this->createMock(FilesystemOperator::class);
$parser = $this->createMock(ContingentParser::class);
$filesystem->method('listContents')->willReturn(new DirectoryListing([
new FileAttributes('HotelZimmer_100.xml', 10),
new FileAttributes('HotelZimmer_200.xml', 10),
new FileAttributes('other.xml', 10),
]));
$item = $this->createMock(ItemInterface::class);
$item->expects($this->once())->method('expiresAfter')->with(3 * 60 * 60);
$item->expects($this->once())->method('tag')->with(['xml-sync']);
$cache->expects($this->once())
->method('get')
->with('bpn_contingent_files', $this->isType('callable'))
->willReturnCallback(fn (string $key, callable $callback) => $callback($item));
$loader = new ContingentLoader($parser, $cache, $filesystem);
$map = $loader->generateFilesMap();
$this->assertSame([
100 => 'HotelZimmer_100.xml',
200 => 'HotelZimmer_200.xml',
], $map);
}
public function testLoadByHotelIdUsesRootLinkOneHopAndTagsCache(): void
{
$cache = $this->createMock(TagAwareCacheInterface::class);
$filesystem = $this->createMock(FilesystemOperator::class);
$parser = $this->createMock(ContingentParser::class);
$originalXml = '<hotelzimmer idbuspro_kontingent_aus="200"><unterbringungen /></hotelzimmer>';
$linkedXml = '<hotelzimmer><unterbringungen /></hotelzimmer>';
$parsed = ['roomTypes' => [], 'rows' => [['roomCode' => 'DZ']]];
$filesystem->method('listContents')->willReturn(new DirectoryListing([
new FileAttributes('HotelZimmer_100.xml', 10),
new FileAttributes('HotelZimmer_200.xml', 10),
]));
$filesystem->method('read')->willReturnMap([
['HotelZimmer_100.xml', $originalXml],
['HotelZimmer_200.xml', $linkedXml],
]);
$mapItem = $this->createMock(ItemInterface::class);
$mapItem->method('expiresAfter')->with(3 * 60 * 60);
$mapItem->method('tag')->with(['xml-sync']);
$hotelItem = $this->createMock(ItemInterface::class);
$hotelItem->method('expiresAfter')->with(3600);
$hotelItem->method('tag')->with(['xml-sync']);
$cache->method('get')->willReturnCallback(
function (string $key, callable $callback) use ($mapItem, $hotelItem) {
if ('bpn_contingent_files' === $key) {
return $callback($mapItem);
}
if ('contingent_hotel_100' === $key) {
return $callback($hotelItem);
}
return null;
}
);
$parser->expects($this->once())->method('parse')->with($linkedXml)->willReturn($parsed);
$loader = new ContingentLoader($parser, $cache, $filesystem);
$result = $loader->loadByHotelId(100);
$this->assertSame($parsed, $result);
}
public function testLoadByHotelIdReturnsEmptyWhenLinkedTargetIsMissing(): void
{
$cache = $this->createMock(TagAwareCacheInterface::class);
$filesystem = $this->createMock(FilesystemOperator::class);
$parser = $this->createMock(ContingentParser::class);
$xml = '<hotelzimmer idbuspro_kontingent_aus="999"><unterbringungen /></hotelzimmer>';
$filesystem->method('listContents')->willReturn(new DirectoryListing([
new FileAttributes('HotelZimmer_100.xml', 10),
]));
$filesystem->method('read')->willReturn($xml);
$mapItem = $this->createMock(ItemInterface::class);
$mapItem->method('expiresAfter')->with(3 * 60 * 60);
$mapItem->method('tag')->with(['xml-sync']);
$hotelItem = $this->createMock(ItemInterface::class);
$hotelItem->method('expiresAfter')->with(3600);
$hotelItem->method('tag')->with(['xml-sync']);
$cache->method('get')->willReturnCallback(
function (string $key, callable $callback) use ($mapItem, $hotelItem) {
if ('bpn_contingent_files' === $key) {
return $callback($mapItem);
}
if ('contingent_hotel_100' === $key) {
return $callback($hotelItem);
}
return null;
}
);
$parser->expects($this->never())->method('parse');
$loader = new ContingentLoader($parser, $cache, $filesystem);
$result = $loader->loadByHotelId(100);
$this->assertSame(['roomTypes' => [], 'rows' => []], $result);
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\XmlParser;
use App\BusProNet\XmlParser\ContingentParser;
use PHPUnit\Framework\TestCase;
class ContingentParserTest extends TestCase
{
private ContingentParser $parser;
protected function setUp(): void
{
$this->parser = new ContingentParser();
}
public function testParseBuildsRoomTypesAndRowsWithLinksAndControlRoom(): void
{
$xml = <<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<hotelzimmer>
<unterbringungen>
<unterbringung idbuspro="10" code="DZ" zimmerbezeichnung="Double Room" pax_max="2" />
<unterbringung idbuspro="11" code="BelKal" zimmerbezeichnung="Control" pax_max="1" />
<unterbringung idbuspro="12" code="PDGS" zimmerbezeichnung="Pseudo" pax_max="1" />
<unterbringung idbuspro="13" code="EZ" zimmerbezeichnung="Single Room" pax_max="1" idbuspro_kontingent_aus="10" />
</unterbringungen>
<kapazitaeten>
<kapazitaet termin="24.02.2026">
<zimmerliste>
<zimmer idbuspro="10" kontingent="3" frei="1" status="" abpreis="100,00" abpreis_naechte="2" abpreis_verlaengerung="15,00" abpreis_verlaengerung_naechte="1" />
<zimmer idbuspro="11" kontingent="1" frei="1" status="A" abpreis="0" abpreis_naechte="0" abpreis_verlaengerung="0" abpreis_verlaengerung_naechte="0" />
</zimmerliste>
</kapazitaet>
</kapazitaeten>
</hotelzimmer>
XML;
$result = $this->parser->parse($xml);
$this->assertArrayHasKey('roomTypes', $result);
$this->assertArrayHasKey('rows', $result);
$this->assertCount(3, $result['roomTypes']); // PDGS skipped
$this->assertCount(3, $result['rows']);
$rowsByCode = [];
foreach ($result['rows'] as $row) {
$rowsByCode[$row['roomCode']] = $row;
}
$this->assertSame('OK', $rowsByCode['DZ']['status']);
$this->assertSame(6, $rowsByCode['DZ']['pax']);
$this->assertSame(2, $rowsByCode['DZ']['available']);
$this->assertSame(100.0, $rowsByCode['DZ']['minPrice']);
$this->assertSame(2, $rowsByCode['DZ']['minNights']);
$this->assertSame(15.0, $rowsByCode['DZ']['additionalNightMinPrice']);
$this->assertSame(1, $rowsByCode['DZ']['additionalNightMinNights']);
$this->assertFalse($rowsByCode['DZ']['isControlRoom']);
// Linked room uses DZ contingent node but its own pax
$this->assertSame('Single Room', $rowsByCode['EZ']['roomLabel']);
$this->assertSame(3, $rowsByCode['EZ']['pax']);
$this->assertSame(1, $rowsByCode['EZ']['available']);
$this->assertSame('ON_REQUEST', $rowsByCode['BelKal']['status']);
$this->assertSame(0, $rowsByCode['BelKal']['pax']);
$this->assertSame(0, $rowsByCode['BelKal']['available']);
$this->assertTrue($rowsByCode['BelKal']['isControlRoom']);
}
public function testParseHandlesZeroValuesAndBlockedStatus(): void
{
$xml = <<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<hotelzimmer>
<unterbringungen>
<unterbringung idbuspro="20" code="DZ" zimmerbezeichnung="Double Room" pax_max="2" />
</unterbringungen>
<kapazitaeten>
<kapazitaet termin="25.02.2026">
<zimmerliste>
<zimmer idbuspro="20" kontingent="0" frei="0" status="S" abpreis="0" abpreis_naechte="0" abpreis_verlaengerung="0" abpreis_verlaengerung_naechte="0" />
</zimmerliste>
</kapazitaet>
</kapazitaeten>
</hotelzimmer>
XML;
$result = $this->parser->parse($xml);
$row = $result['rows'][0];
$this->assertSame('BLOCKED', $row['status']);
$this->assertSame(0, $row['pax']);
$this->assertSame(0, $row['available']);
$this->assertSame(0.0, $row['minPrice']);
$this->assertSame(0, $row['minNights']);
$this->assertSame(0.0, $row['additionalNightMinPrice']);
$this->assertSame(0, $row['additionalNightMinNights']);
}
}