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']);
}
}
+74 -29
View File
@@ -6,6 +6,8 @@ namespace App\Tests\Command;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Command\BpnXmlSyncCommand;
use App\Service\BpnXmlSyncManager;
use App\Service\BpnXmlSnapshotRefreshManager;
use App\Service\TravelDataProvider;
use App\Service\TravelSnapshotManager;
use League\Flysystem\DirectoryListing;
@@ -14,17 +16,21 @@ use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class BpnXmlSyncCommandTest extends TestCase
{
private FilesystemOperator $xmlSource;
private FilesystemOperator $xmlExport;
private CacheInterface $cache;
private FilesystemOperator $xmlSourceContingents;
private FilesystemOperator $xmlExportContingents;
private TagAwareCacheInterface $cache;
private LoggerInterface $logger;
private TravelLoader $travelLoader;
private TravelDataProvider $travelDataService;
private TravelSnapshotManager $travelSnapshotService;
private BpnXmlSnapshotRefreshManager $snapshotRefreshManager;
private BpnXmlSyncManager $syncManager;
private BpnXmlSyncCommand $command;
protected function tearDown(): void
@@ -39,40 +45,55 @@ class BpnXmlSyncCommandTest extends TestCase
{
$this->xmlSource = $this->createMock(FilesystemOperator::class);
$this->xmlExport = $this->createMock(FilesystemOperator::class);
$this->cache = $this->createMock(CacheInterface::class);
$this->xmlSourceContingents = $this->createMock(FilesystemOperator::class);
$this->xmlExportContingents = $this->createMock(FilesystemOperator::class);
$this->cache = $this->createMock(TagAwareCacheInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->travelLoader = $this->createMock(TravelLoader::class);
$this->travelDataService = $this->createMock(TravelDataProvider::class);
$this->travelSnapshotService = $this->createMock(TravelSnapshotManager::class);
$this->command = new BpnXmlSyncCommand(
$this->xmlSource,
$this->xmlExport,
$this->cache,
$this->logger,
$this->snapshotRefreshManager = new BpnXmlSnapshotRefreshManager(
$this->travelLoader,
$this->travelDataService,
$this->travelSnapshotService,
$this->logger,
);
$this->syncManager = new BpnXmlSyncManager(
$this->xmlSource,
$this->xmlExport,
$this->xmlSourceContingents,
$this->xmlExportContingents,
$this->cache,
$this->logger,
);
$this->command = new BpnXmlSyncCommand(
$this->syncManager,
$this->snapshotRefreshManager,
$this->logger,
);
}
/**
* Remote newer than local files downloaded syncSnapshotsFromXml() called once.
* Remote newer than local -> files downloaded -> syncSnapshotsFromXml() called once.
*/
public function testSnapshotSyncIsTriggeredAfterSuccessfulFileDownload(): void
{
$this->xmlSource->method('read')
->willReturn("24.03.2026 12:00:00\nExport\n3 Dateien\n");
$newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n";
$olderTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n";
$this->xmlSource->method('read')->willReturn($newerTimestamp);
$this->xmlExport->method('fileExists')->willReturn(true);
$this->xmlExport->method('read')
->willReturn("24.03.2026 10:00:00\nExport\n3 Dateien\n");
$this->xmlExport->method('read')->willReturn($olderTimestamp);
$this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
// No remote files to copy (keeps syncFiles() trivial while still completing).
$this->xmlSource->method('listContents')
->willReturn(new DirectoryListing([]));
$this->xmlExport->method('listContents')
->willReturn(new DirectoryListing([]));
$this->xmlSourceContingents->method('read')->willReturn($olderTimestamp);
$this->xmlExportContingents->method('fileExists')->willReturn(true);
$this->xmlExportContingents->method('read')->willReturn($olderTimestamp);
$this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([]));
$fileMap = [
101 => ['hotels' => ['H1' => null, 'H2' => null]],
@@ -87,6 +108,15 @@ class BpnXmlSyncCommandTest extends TestCase
->with($fileMap, $this->isInstanceOf(\Closure::class))
->willReturn(['processed' => 2, 'failed' => 0]);
$this->travelSnapshotService->expects($this->once())
->method('purgeOrphanedFutureSnapshots')
->with([101, 102])
->willReturn(0);
$this->cache->expects($this->once())
->method('invalidateTags')
->with(['xml-sync']);
$tester = new CommandTester($this->command);
$tester->execute([]);
@@ -94,7 +124,7 @@ class BpnXmlSyncCommandTest extends TestCase
}
/**
* Local already up to date early return before snapshot code is reached.
* Local already up to date -> early return before snapshot code is reached.
*/
public function testSnapshotSyncIsSkippedWhenLocalDataIsUpToDate(): void
{
@@ -102,9 +132,19 @@ class BpnXmlSyncCommandTest extends TestCase
$this->xmlSource->method('read')->willReturn($sameTimestamp);
$this->xmlExport->method('fileExists')->willReturn(true);
$this->xmlExport->method('read')->willReturn($sameTimestamp);
$this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlSourceContingents->method('read')->willReturn($sameTimestamp);
$this->xmlExportContingents->method('fileExists')->willReturn(true);
$this->xmlExportContingents->method('read')->willReturn($sameTimestamp);
$this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->travelLoader->expects($this->never())->method('generateFilesMap');
$this->travelDataService->expects($this->never())->method('syncSnapshotsFromXml');
$this->travelSnapshotService->expects($this->never())->method('purgeOrphanedFutureSnapshots');
$this->cache->expects($this->never())->method('invalidateTags');
$tester = new CommandTester($this->command);
$tester->execute([]);
@@ -113,22 +153,25 @@ class BpnXmlSyncCommandTest extends TestCase
}
/**
* generateFilesMap() throws warning logged, syncSnapshotsFromXml() never called,
* generateFilesMap() throws -> warning logged, syncSnapshotsFromXml() never called,
* command still returns SUCCESS.
*/
public function testSnapshotSyncIsSkippedAndWarningLoggedWhenFileMapGenerationFails(): void
{
$this->xmlSource->method('read')
->willReturn("24.03.2026 12:00:00\nExport\n3 Dateien\n");
$newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n";
$olderTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n";
$this->xmlSource->method('read')->willReturn($newerTimestamp);
$this->xmlExport->method('fileExists')->willReturn(true);
$this->xmlExport->method('read')
->willReturn("24.03.2026 10:00:00\nExport\n3 Dateien\n");
$this->xmlExport->method('read')->willReturn($olderTimestamp);
$this->xmlSource->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExport->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlSource->method('listContents')
->willReturn(new DirectoryListing([]));
$this->xmlExport->method('listContents')
->willReturn(new DirectoryListing([]));
$this->xmlSourceContingents->method('read')->willReturn($olderTimestamp);
$this->xmlExportContingents->method('fileExists')->willReturn(true);
$this->xmlExportContingents->method('read')->willReturn($olderTimestamp);
$this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([]));
$this->travelLoader->expects($this->once())
->method('generateFilesMap')
@@ -142,6 +185,8 @@ class BpnXmlSyncCommandTest extends TestCase
);
$this->travelDataService->expects($this->never())->method('syncSnapshotsFromXml');
$this->travelSnapshotService->expects($this->never())->method('purgeOrphanedFutureSnapshots');
$this->cache->expects($this->once())->method('invalidateTags')->with(['xml-sync']);
$tester = new CommandTester($this->command);
$tester->execute([]);
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\Controller\Api\ContingentController;
use App\Service\ContingentDataService;
use App\Service\TravelDataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
class ContingentControllerTest extends TestCase
{
public function testByDateQueryResolvesCodeReferences(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$travelDataProvider
->expects($this->once())
->method('mapHotelCodeToId')
->with('SBW-HOTEL')
->willReturn(157047);
$travelDataProvider
->expects($this->once())
->method('mapDateCodeToId')
->with('SBW131225')
->willReturn(11606);
$contingentDataService
->expects($this->once())
->method('getAvailableContingents')
->with(157047, 11606)
->willReturn([]);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents', 'GET', [
'hotelRef' => 'SBW-HOTEL',
'dateRef' => 'sbw-13/12/25',
]);
$response = $controller->byDate($request);
$this->assertSame(200, $response->getStatusCode());
}
public function testByDateQueryRequiresDateReference(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents', 'GET', [
'hotelRef' => '157047',
]);
$response = $controller->byDate($request);
$payload = json_decode((string) $response->getContent(), true);
$this->assertSame(400, $response->getStatusCode());
$this->assertSame('dateRef is required', $payload['error']);
}
public function testByDateQueryReturnsNotFoundForUnknownDateReference(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$travelDataProvider
->expects($this->once())
->method('mapHotelCodeToId')
->with('SBW-HOTEL')
->willReturn(157047);
$travelDataProvider
->expects($this->once())
->method('mapDateCodeToId')
->with('UNKNOWN')
->willReturn(null);
$contingentDataService
->expects($this->never())
->method('getAvailableContingents');
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents', 'GET', [
'hotelRef' => 'SBW-HOTEL',
'dateRef' => 'unknown',
]);
$response = $controller->byDate($request);
$payload = json_decode((string) $response->getContent(), true);
$this->assertSame(404, $response->getStatusCode());
$this->assertSame('Not found', $payload['message']);
}
public function testRoomsQueryRequiresDateRange(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents/rooms', 'GET', [
'hotelRef' => '157047',
'dateRef' => '11606',
]);
$response = $controller->rooms($request);
$payload = json_decode((string) $response->getContent(), true);
$this->assertSame(400, $response->getStatusCode());
$this->assertSame('dateFrom and dateTo are required', $payload['error']);
}
public function testByDateQueryUsesNumericReferencesAsIdentifiers(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$travelDataProvider
->expects($this->never())
->method('mapHotelCodeToId');
$travelDataProvider
->expects($this->never())
->method('mapDateCodeToId');
$contingentDataService
->expects($this->once())
->method('getAvailableContingents')
->with(157047, 11606)
->willReturn([]);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents', 'GET', [
'hotelRef' => '157047',
'dateRef' => '11606',
]);
$response = $controller->byDate($request);
$this->assertSame(200, $response->getStatusCode());
}
public function testRoomsQueryForwardsOptionalMyEpUrl(): void
{
$contingentDataService = $this->createMock(ContingentDataService::class);
$travelDataProvider = $this->createMock(TravelDataProvider::class);
$contingentDataService
->expects($this->once())
->method('getAvailableRooms')
->with('2026-03-01', '2026-03-08', 157047, 11606, 'https://my.ep-reisen.de')
->willReturn([]);
$controller = $this->createController($contingentDataService, $travelDataProvider);
$request = Request::create('/api/contingents/rooms', 'GET', [
'hotelRef' => '157047',
'dateRef' => '11606',
'dateFrom' => '2026-03-01',
'dateTo' => '2026-03-08',
'my_ep_url' => 'https://my.ep-reisen.de',
]);
$response = $controller->rooms($request);
$this->assertSame(200, $response->getStatusCode());
}
private function createController(
ContingentDataService $contingentDataService,
TravelDataProvider $travelDataProvider,
): ContingentController {
$controller = new ContingentController($contingentDataService, $travelDataProvider);
$container = new Container();
$container->set('serializer', new Serializer([new ObjectNormalizer()], [new JsonEncoder()]));
$controller->setContainer($container);
return $controller;
}
}
+1 -6
View File
@@ -45,12 +45,7 @@ class ParticipantEditDtoTest extends TestCase
$mockServiceAgeEvaluator = $this->createMock(ServiceAgeEvaluator::class);
// Create custom validator factory that can inject dependencies
$validatorFactory = new class(
$mockVoucherService,
$mockPriceCalculatorService,
$mockParticipantEligibilityChecker,
$mockServiceAgeEvaluator
) implements ConstraintValidatorFactoryInterface {
$validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService, $mockParticipantEligibilityChecker, $mockServiceAgeEvaluator) implements ConstraintValidatorFactoryInterface {
public function __construct(
private readonly VoucherValidator $voucherService,
private readonly BookingPriceCalculator $priceCalculatorService,
@@ -13,8 +13,8 @@ use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantFieldOptionsProvider;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
use App\Service\ServiceLabelFormatter;
use App\Service\ServiceAvailabilityCalculator;
use App\Service\ServiceLabelFormatter;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -12,8 +12,8 @@ use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantFieldOptionsProvider;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
use App\Service\ServiceLabelFormatter;
use App\Service\ServiceAvailabilityCalculator;
use App\Service\ServiceLabelFormatter;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Service\BookingStatusRuleRegistry;
use App\BusProNet\XmlLoader\AgencyLoader;
@@ -8,13 +8,13 @@ use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingCreateContext;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingSummaryDto;
use App\Form\Model\ParticipantCardDataDto;
use App\Form\Model\ParticipantCardPriceDto;
use App\Form\Model\BookingSummaryDto;
use App\Service\BookingCreateContextFactory;
use App\Service\ParticipantCardAssembler;
use App\Service\BookingSummaryAssembler;
use App\Service\BookingPriceCalculator;
use App\Service\BookingSummaryAssembler;
use App\Service\ParticipantCardAssembler;
use App\Service\RoomPricingCalculator;
use PHPUnit\Framework\TestCase;
@@ -9,8 +9,8 @@ use App\BusProNet\Model\Booking;
use App\BusProNet\Model\MutableData;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingMutabilityDto;
use App\Form\Model\BookingEditContext;
use App\Form\Model\BookingMutabilityDto;
use App\Form\Model\BookingSummaryDto;
use App\Service\BookingEditContextFactory;
use App\Service\BookingSummaryAssembler;
@@ -13,9 +13,9 @@ use App\Entity\User;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Repository\BookingEditDraftRepository;
use App\Service\BookingChangeTracker;
use App\Service\BookingEditDraftManager;
use App\Service\BookingEditDraftMerger;
use App\Service\BookingChangeTracker;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
+5 -5
View File
@@ -422,11 +422,11 @@ class BookingEditSubmitterTest extends TestCase
private function createService(
?\App\BusProNet\ApiClient $apiClient = null,
?BookingEditDataLoader $dataLoader = null,
?BookingEditDraftManager $draftService = null,
?TravelDataProvider $travelDataService = null,
?BookingEditSubmitGuard $submitGuard = null,
?BookingSessionManager $bookingSessionService = null,
?BookingEditDataLoader $dataLoader = null,
?BookingEditDraftManager $draftService = null,
?TravelDataProvider $travelDataService = null,
?BookingEditSubmitGuard $submitGuard = null,
?BookingSessionManager $bookingSessionService = null,
): BookingEditSubmitter {
return new BookingEditSubmitter(
$apiClient ?? $this->createMock(\App\BusProNet\ApiClient::class),
@@ -11,8 +11,8 @@ use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingPricingAssembler;
use App\Service\BookingPriceMismatchAnalyzer;
use App\Service\BookingPricingAssembler;
use PHPUnit\Framework\TestCase;
class BookingPriceMismatchAnalyzerTest extends TestCase
@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\DataProvider\CountryDataProvider;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingSummaryDto;
use App\Form\Model\ParticipantDto;
@@ -15,8 +17,6 @@ use App\Service\BookingPriceCalculator;
use App\Service\BookingPricingAssembler;
use App\Service\BookingSummaryAssembler;
use App\Service\CmsDataProvider;
use App\BusProNet\DataProvider\CountryDataProvider;
use App\BusProNet\XmlLoader\HotelLoader;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Contracts\Cache\CacheInterface;
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\BookingUrlUtility;
use App\BusProNet\XmlLoader\ContingentLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Service\ContingentDataService;
use PHPUnit\Framework\TestCase;
class ContingentDataServiceTest extends TestCase
{
private ContingentLoader $contingentLoader;
private TravelLoader $travelLoader;
private ContingentDataService $service;
protected function setUp(): void
{
$this->contingentLoader = $this->createMock(ContingentLoader::class);
$this->travelLoader = $this->createMock(TravelLoader::class);
$this->service = new ContingentDataService(
$this->contingentLoader,
$this->travelLoader,
new BookingUrlUtility('https://my.ep-reisen.de'),
);
}
public function testGetAvailableContingentsAggregatesAndAppliesBelKalOverride(): void
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
$travel->dateTo = new \DateTimeImmutable('2026-02-03');
$this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel);
$this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([
'rows' => [
[
'date' => new \DateTimeImmutable('2026-02-01'),
'isControlRoom' => false,
'available' => 4,
'pax' => 8,
'minNights' => 3,
'status' => 'OK',
],
[
'date' => new \DateTimeImmutable('2026-02-01'),
'isControlRoom' => false,
'available' => 2,
'pax' => 4,
'minNights' => 2,
'status' => 'OK',
],
[
'date' => new \DateTimeImmutable('2026-02-01'),
'isControlRoom' => true,
'available' => 0,
'pax' => 0,
'minNights' => null,
'status' => 'BLOCKED',
],
],
]);
$result = $this->service->getAvailableContingents(10, 123);
$this->assertArrayHasKey('2026-02-01', $result);
$summary = $result['2026-02-01'];
$this->assertSame('2026-02-01', $summary->date);
$this->assertSame(2, $summary->minNights);
$this->assertSame(12, $summary->capacity);
$this->assertSame(0, $summary->total); // BelKal override
}
public function testGetAvailableRoomsRejectsRangeOutsideTravelPeriod(): void
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2026-02-10');
$travel->dateTo = new \DateTimeImmutable('2026-02-20');
$this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Requested range must be within the travel date range');
$this->service->getAvailableRooms('2026-02-01', '2026-02-05', 10, 123);
}
public function testGetAvailableRoomsCalculatesPriceAndAppliesBelKalStatusOverride(): void
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
$travel->dateTo = new \DateTimeImmutable('2026-02-10');
$this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel);
$this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([
'rows' => [
[
'date' => new \DateTimeImmutable('2026-02-03'),
'isControlRoom' => true,
'status' => 'ON_REQUEST',
],
[
'date' => new \DateTimeImmutable('2026-02-03'),
'isControlRoom' => false,
'roomCode' => 'DZ',
'roomLabel' => 'Double',
'pax' => 4,
'available' => 2,
'status' => 'OK',
'minPrice' => 100.0,
'minNights' => 2,
'additionalNightMinPrice' => 30.0,
'additionalNightMinNights' => 1,
],
],
]);
$result = $this->service->getAvailableRooms('2026-02-03', '2026-02-06', 10, 123);
$this->assertCount(1, $result);
$room = $result[0];
$this->assertSame('2026-02-03', $room->date);
$this->assertSame('ON_REQUEST', $room->status);
$this->assertSame(0, $room->available); // overridden
$this->assertSame(130.0, $room->priceForSelection); // 100 + (3-2)*30
$this->assertSame('https://my.ep-reisen.de/bookings/create?date_id=123&hotel_id=10', $room->bookingUrl);
}
public function testGetCalendarEventsUsesWorstStatusAndExcludesControlRoomFromSums(): void
{
$this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([
'rows' => [
[
'date' => new \DateTimeImmutable('2026-02-03'),
'isControlRoom' => false,
'status' => 'OK',
'pax' => 6,
'available' => 2,
],
[
'date' => new \DateTimeImmutable('2026-02-03'),
'isControlRoom' => true,
'status' => 'BLOCKED',
'pax' => 0,
'available' => 0,
],
],
]);
$events = $this->service->getCalendarEvents(10, '2026-02-01', '2026-02-10');
$this->assertCount(1, $events);
$event = $events[0];
$this->assertSame('2026-02-03', $event->date);
$this->assertSame('BLOCKED', $event->status);
$this->assertSame(6, $event->pax);
$this->assertSame(2, $event->available);
}
}
@@ -176,4 +176,3 @@ class MandatoryAdditionalServicesSelectedValidatorTest extends ConstraintValidat
return $service;
}
}
+2 -2
View File
@@ -3,9 +3,9 @@
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__).'/vendor/autoload.php';
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();