feat: simplify booking pricing flow

This commit is contained in:
Björn Fromme
2026-04-11 18:28:32 +02:00
parent a0cbd5a23f
commit 346256f895
10 changed files with 1561 additions and 1267 deletions
@@ -16,38 +16,24 @@ use App\Service\InsuranceService;
use App\Service\ParticipantEligibilityService;
use App\Service\ParticipantPricingCalculator;
use App\Service\RoomPricingCalculator;
use App\Service\ServicePricingCalculator;
use PHPUnit\Framework\TestCase;
class BookingPriceCalculatorServiceTest extends TestCase
{
private BookingPriceCalculatorService $service;
private ParticipantEligibilityService $participantEligibilityService;
private RoomPricingCalculator $roomPricingCalculator;
private ParticipantPricingCalculator $participantPricingCalculator;
protected function setUp(): void
{
$this->participantEligibilityService = $this->createMock(ParticipantEligibilityService::class);
$this->participantEligibilityService->method('isParticipantEligible')->willReturn(true);
// Build the calculator chain
$roomPricingCalculator = new RoomPricingCalculator();
$participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator);
$this->roomPricingCalculator = new RoomPricingCalculator();
$this->participantPricingCalculator = new ParticipantPricingCalculator($this->roomPricingCalculator);
// Create InsuranceService with a mock price calculator (to avoid circular reference in tests)
$mockPriceCalculator = $this->createMock(BookingPriceCalculatorService::class);
$insuranceService = new InsuranceService($mockPriceCalculator);
$servicePricingCalculator = new ServicePricingCalculator(
$this->participantEligibilityService,
$insuranceService,
$participantPricingCalculator
);
$this->service = new BookingPriceCalculatorService(
$roomPricingCalculator,
$servicePricingCalculator,
$participantPricingCalculator
);
$this->service = $this->createService($this->participantEligibilityService);
}
public function testCalculateRoomPricingWithParticipantBasedCalculation(): void
@@ -613,24 +599,7 @@ class BookingPriceCalculatorServiceTest extends TestCase
$participantEligibilityService->method('isParticipantEligible')
->willReturnCallback(fn ($booking, $index) => 0 === $index); // Only first participant eligible
// Build the calculator chain with the custom eligibility service
$roomPricingCalculator = new RoomPricingCalculator();
$participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator);
$mockPriceCalculator = $this->createMock(BookingPriceCalculatorService::class);
$insuranceService = new InsuranceService($mockPriceCalculator);
$servicePricingCalculator = new ServicePricingCalculator(
$participantEligibilityService,
$insuranceService,
$participantPricingCalculator
);
$this->service = new BookingPriceCalculatorService(
$roomPricingCalculator,
$servicePricingCalculator,
$participantPricingCalculator
);
$this->service = $this->createService($participantEligibilityService);
$result = $this->service->calculateServicePricing($bookingDto);
@@ -725,4 +694,14 @@ class BookingPriceCalculatorServiceTest extends TestCase
return $participant;
}
private function createService(ParticipantEligibilityService $participantEligibilityService): BookingPriceCalculatorService
{
return new BookingPriceCalculatorService(
$this->roomPricingCalculator,
$participantEligibilityService,
new InsuranceService(),
$this->participantPricingCalculator,
);
}
}
+147 -301
View File
@@ -5,14 +5,12 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\InsuranceLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Exception\TravelNotFoundException;
use App\Service\TravelDataService;
use App\Service\TravelEnrichmentService;
use App\Service\TravelLookupService;
use App\Service\TravelSnapshotService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -22,34 +20,31 @@ class TravelDataServiceTest extends TestCase
{
private TravelDataService $service;
private TravelLoader $travelLoader;
private HotelLoader $hotelLoader;
private PickupLoader $pickupLoader;
private InsuranceLoader $insuranceLoader;
private ApiClient $apiClient;
private CacheInterface $cache;
private LoggerInterface $logger;
private TravelSnapshotService $travelSnapshotService;
private TravelLookupService $travelLookupService;
private TravelEnrichmentService $travelEnrichmentService;
protected function setUp(): void
{
$this->travelLoader = $this->createMock(TravelLoader::class);
$this->hotelLoader = $this->createMock(HotelLoader::class);
$this->pickupLoader = $this->createMock(PickupLoader::class);
$this->insuranceLoader = $this->createMock(InsuranceLoader::class);
$this->apiClient = $this->createMock(ApiClient::class);
$this->cache = $this->createMock(CacheInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->travelSnapshotService = $this->createMock(TravelSnapshotService::class);
$this->travelLookupService = $this->createMock(TravelLookupService::class);
$this->travelEnrichmentService = $this->createMock(TravelEnrichmentService::class);
$this->service = new TravelDataService(
$this->travelLoader,
$this->hotelLoader,
$this->pickupLoader,
$this->insuranceLoader,
$this->apiClient,
$this->cache,
$this->logger,
$this->travelSnapshotService,
$this->travelLookupService,
$this->travelEnrichmentService,
false, // preferRemote
true, // enableFallback
);
@@ -69,15 +64,45 @@ class TravelDataServiceTest extends TestCase
->with($dateId, $hotelId)
->willReturn($travel);
$this->pickupLoader
$this->travelEnrichmentService
->expects($this->once())
->method('patchPickupsDetails')
->method('enrichFromXml')
->with($travel)
->willReturn(true);
$this->travelSnapshotService
->expects($this->once())
->method('upsertFromTravel')
->with($travel);
$this->hotelLoader
$result = $this->service->getTravelDataFromXml($dateId, $hotelId);
$this->assertSame($travel, $result);
}
public function testGetTravelDataFromXmlSkipsSnapshotWhenEnrichmentFails(): void
{
$dateId = 12345;
$hotelId = 67890;
$travel = new Travel();
$travel->id = $dateId;
$travel->hotelId = $hotelId;
$this->travelLoader
->expects($this->once())
->method('patchHotelDetails')
->with($travel);
->method('loadById')
->with($dateId, $hotelId)
->willReturn($travel);
$this->travelEnrichmentService
->expects($this->once())
->method('enrichFromXml')
->with($travel)
->willReturn(false);
$this->travelSnapshotService
->expects($this->never())
->method('upsertFromTravel');
$result = $this->service->getTravelDataFromXml($dateId, $hotelId);
@@ -95,13 +120,9 @@ class TravelDataServiceTest extends TestCase
->with($dateId, $hotelId)
->willThrowException(new TravelNotFoundException($dateId));
$this->pickupLoader
$this->travelEnrichmentService
->expects($this->never())
->method('patchPickupsDetails');
$this->hotelLoader
->expects($this->never())
->method('patchHotelDetails');
->method('enrichFromXml');
$this->expectException(TravelNotFoundException::class);
$this->service->getTravelDataFromXml($dateId, $hotelId);
@@ -116,7 +137,7 @@ class TravelDataServiceTest extends TestCase
$travel->id = $dateId;
$travel->hotelId = $hotelId;
$this->travelLoader
$this->travelLookupService
->expects($this->once())
->method('mapDateIdToProductId')
->with($dateId)
@@ -128,17 +149,60 @@ class TravelDataServiceTest extends TestCase
->with($productId, $hotelId)
->willReturn($travel);
$this->travelEnrichmentService
->expects($this->once())
->method('patchInsurances')
->with($travel);
$this->travelSnapshotService
->expects($this->once())
->method('upsertFromTravel')
->with($travel);
$result = $this->service->getTravelDataFromApi($dateId, $hotelId);
$this->assertSame($travel, $result);
}
public function testGetTravelDataFromApiSnapshotFailureDoesNotDiscardApiTravel(): void
{
$dateId = 12345;
$hotelId = 67890;
$productId = 555;
$travel = new Travel();
$travel->id = $dateId;
$travel->hotelId = $hotelId;
$this->travelLookupService
->method('mapDateIdToProductId')
->willReturn($productId);
$this->apiClient
->method('getTravelData')
->willReturn($travel);
$this->travelSnapshotService
->expects($this->once())
->method('upsertFromTravel')
->willThrowException(new \RuntimeException('DB write failed'));
$this->logger
->expects($this->once())
->method('warning')
->with('Failed to persist travel snapshot after API load', $this->arrayHasKey('dateId'));
$result = $this->service->getTravelDataFromApi($dateId, $hotelId);
$this->assertNotNull($result);
$this->assertSame($travel, $result);
}
public function testGetTravelDataFromApiCannotMapDateId(): void
{
$dateId = 12345;
$hotelId = 67890;
$this->travelLoader
$this->travelLookupService
->expects($this->once())
->method('mapDateIdToProductId')
->with($dateId)
@@ -153,175 +217,45 @@ class TravelDataServiceTest extends TestCase
$this->assertNull($result);
}
public function testExistsLocallyTrueFromSnapshot(): void
public function testExistsLocallyDelegatesToLookupService(): void
{
$dateId = 12345;
$hotelId = 67890;
$this->travelSnapshotService
$this->travelLookupService
->expects($this->once())
->method('exists')
->method('existsLocally')
->with($dateId, $hotelId)
->willReturn(true);
$this->travelLoader
->expects($this->never())
->method('generateFilesMap');
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertTrue($result);
$this->assertTrue($this->service->existsLocally($dateId, $hotelId));
}
public function testExistsLocallyTrueFromXmlMap(): void
public function testGetAvailableSourcesDelegatesToLookupService(): void
{
$dateId = 12345;
$hotelId = 67890;
$mapping = [
$dateId => [
'id' => $dateId,
'hotels' => [
$hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'],
],
],
];
$expected = ['local' => true, 'remote' => true];
$this->travelSnapshotService
$this->travelLookupService
->expects($this->once())
->method('exists')
->method('getAvailableSources')
->with($dateId, $hotelId)
->willReturn(false);
->willReturn($expected);
$this->travelLoader
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertTrue($result);
$this->assertSame($expected, $this->service->getAvailableSources($dateId, $hotelId));
}
public function testExistsLocallyFalseNoTravel(): void
public function testGenerateFilesMapDelegatesToLookupService(): void
{
$dateId = 12345;
$hotelId = 67890;
$mapping = [];
$mapping = [12345 => ['id' => 12345, 'hotels' => []]];
$this->travelSnapshotService
->expects($this->once())
->method('exists')
->with($dateId, $hotelId)
->willReturn(false);
$this->travelLoader
$this->travelLookupService
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertFalse($result);
}
public function testExistsLocallyFalseNoHotel(): void
{
$dateId = 12345;
$hotelId = 67890;
$mapping = [
$dateId => [
'id' => $dateId,
'hotels' => [],
],
];
$this->travelSnapshotService
->expects($this->once())
->method('exists')
->with($dateId, $hotelId)
->willReturn(false);
$this->travelLoader
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertFalse($result);
}
public function testGetAvailableSources(): void
{
$dateId = 12345;
$hotelId = 67890;
$productId = 555;
$mapping = [
$dateId => [
'id' => $dateId,
'hotels' => [
$hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'],
],
],
];
$this->travelSnapshotService
->expects($this->once())
->method('exists')
->with($dateId, $hotelId)
->willReturn(false);
$this->travelLoader
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$this->travelLoader
->expects($this->once())
->method('mapDateIdToProductId')
->with($dateId)
->willReturn($productId);
$result = $this->service->getAvailableSources($dateId, $hotelId);
$this->assertEquals([
'local' => true,
'remote' => true,
], $result);
}
public function testGenerateFilesMapIsMemoizedWithinRequest(): void
{
$dateId = 12345;
$hotelId = 67890;
$mapping = [
$dateId => [
'id' => $dateId,
'hotels' => [
$hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'],
],
],
];
// Both existsLocally() calls go through generateFilesMap(); the underlying
// loader and snapshot service must each be invoked only once.
$this->travelSnapshotService
->expects($this->exactly(2))
->method('exists')
->willReturn(false);
$this->travelLoader
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$this->travelSnapshotService
->expects($this->once())
->method('generateMapping')
->willReturn([]);
$this->service->existsLocally($dateId, $hotelId);
$this->service->existsLocally($dateId, $hotelId);
$this->assertSame($mapping, $this->service->generateFilesMap());
}
public function testGetTravelDataFromLocalPrefersSnapshot(): void
@@ -342,53 +276,14 @@ class TravelDataServiceTest extends TestCase
->expects($this->never())
->method('loadById');
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
$this->assertSame($travel, $result);
}
public function testGetTravelDataFromLocalReplacesSnapshotInsurancesWithLoaderData(): void
{
$dateId = 12345;
$hotelId = 67890;
$travel = new Travel();
$travel->id = $dateId;
$travel->hotelId = $hotelId;
// Snapshot-provided insurances are intentionally replaced by InsuranceLoader data.
$staleSnapshotInsurance = new Insurance();
$staleSnapshotInsurance->id = 'stale';
$travel->insurances = [$staleSnapshotInsurance];
$individual = new Insurance();
$individual->id = '10';
$individual->package = false;
$package = new Insurance();
$package->id = '20';
$package->package = true;
$package->containedInsuranceIds = ['10'];
// InsuranceParser populates containedInsurances at parse time; loadAll() returns hydrated data.
$package->containedInsurances = [$individual];
$this->travelSnapshotService
$this->travelEnrichmentService
->expects($this->once())
->method('loadTravel')
->with($dateId, $hotelId)
->willReturn($travel);
$this->insuranceLoader
->expects($this->once())
->method('loadAll')
->willReturn([$individual, $package]);
->method('patchInsurances')
->with($travel);
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
$this->assertSame($travel, $result);
$this->assertSame([$individual, $package], array_values($travel->insurances));
$this->assertCount(1, $package->containedInsurances);
$this->assertSame($individual, $package->containedInsurances[0]);
}
public function testGetTravelDataFromLocalFallsBackToXmlWhenSnapshotMissing(): void
@@ -411,105 +306,23 @@ class TravelDataServiceTest extends TestCase
->with($dateId, $hotelId)
->willReturn($travel);
$this->pickupLoader
$this->travelEnrichmentService
->expects($this->once())
->method('patchPickupsDetails')
->with($travel);
$this->hotelLoader
->expects($this->once())
->method('patchHotelDetails')
->with($travel);
->method('enrichFromXml')
->with($travel)
->willReturn(true);
$this->travelSnapshotService
->expects($this->once())
->method('upsertFromTravel')
->with($travel);
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
$this->assertSame($travel, $result);
}
public function testGetTravelDataFromLocalAttachesLoaderInsurancesFromXmlFallback(): void
{
$dateId = 12345;
$hotelId = 67890;
$individual = new Insurance();
$individual->id = '10';
$individual->package = false;
$package = new Insurance();
$package->id = '20';
$package->package = true;
$package->containedInsuranceIds = ['10'];
// InsuranceParser populates containedInsurances at parse time; loadAll() returns hydrated data.
$package->containedInsurances = [$individual];
$travel = new Travel();
$travel->id = $dateId;
$travel->hotelId = $hotelId;
$this->travelSnapshotService
$this->travelEnrichmentService
->expects($this->once())
->method('loadTravel')
->with($dateId, $hotelId)
->willReturn(null);
$this->travelLoader
->expects($this->once())
->method('loadById')
->with($dateId, $hotelId)
->willReturn($travel);
$this->pickupLoader->expects($this->once())->method('patchPickupsDetails')->with($travel);
$this->hotelLoader->expects($this->once())->method('patchHotelDetails')->with($travel);
$this->insuranceLoader->expects($this->once())->method('loadAll')->willReturn([$individual, $package]);
$this->travelSnapshotService->expects($this->once())->method('upsertFromTravel')->with($travel);
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
$this->assertSame($travel, $result);
$this->assertCount(1, $package->containedInsurances);
$this->assertSame($individual, $package->containedInsurances[0]);
}
public function testGetTravelDataDelegatesToLoadUncached(): void
{
$dateId = 12345;
$hotelId = 67890;
$travel = new Travel();
$travel->id = $dateId;
$travel->hotelId = $hotelId;
$this->cache
->expects($this->never())
->method('get');
$this->travelSnapshotService
->expects($this->once())
->method('loadTravel')
->with($dateId, $hotelId)
->willReturn(null);
$this->travelLoader
->expects($this->once())
->method('loadById')
->with($dateId, $hotelId)
->willReturn($travel);
$this->pickupLoader
->expects($this->once())
->method('patchPickupsDetails')
->method('patchInsurances')
->with($travel);
$this->hotelLoader
->expects($this->once())
->method('patchHotelDetails')
->with($travel);
$result = $this->service->getTravelData($dateId, $hotelId, false);
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
$this->assertSame($travel, $result);
}
@@ -579,6 +392,10 @@ class TravelDataServiceTest extends TestCase
->with($dateId, $hotelId)
->willReturn($travel);
$this->travelEnrichmentService
->method('enrichFromXml')
->willReturn(true);
$this->travelSnapshotService
->expects($this->once())
->method('upsertFromTravel')
@@ -594,4 +411,33 @@ class TravelDataServiceTest extends TestCase
$this->assertNotNull($result);
$this->assertSame($travel, $result);
}
public function testGetTravelDataDelegatesToLoadUncached(): void
{
$dateId = 12345;
$hotelId = 67890;
$travel = new Travel();
$travel->id = $dateId;
$travel->hotelId = $hotelId;
$this->cache
->expects($this->never())
->method('get');
$this->travelSnapshotService
->expects($this->once())
->method('loadTravel')
->with($dateId, $hotelId)
->willReturn(null);
$this->travelLoader
->expects($this->once())
->method('loadById')
->with($dateId, $hotelId)
->willReturn($travel);
$result = $this->service->getTravelData($dateId, $hotelId, false);
$this->assertSame($travel, $result);
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\InsuranceLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\Service\TravelEnrichmentService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class TravelEnrichmentServiceTest extends TestCase
{
private TravelEnrichmentService $service;
private PickupLoader $pickupLoader;
private HotelLoader $hotelLoader;
private InsuranceLoader $insuranceLoader;
private LoggerInterface $logger;
protected function setUp(): void
{
$this->pickupLoader = $this->createMock(PickupLoader::class);
$this->hotelLoader = $this->createMock(HotelLoader::class);
$this->insuranceLoader = $this->createMock(InsuranceLoader::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->service = new TravelEnrichmentService(
$this->pickupLoader,
$this->hotelLoader,
$this->insuranceLoader,
$this->logger,
);
}
// -------------------------------------------------------------------------
// enrichFromXml
// -------------------------------------------------------------------------
public function testEnrichFromXmlReturnsTrueOnSuccess(): void
{
$travel = new Travel();
$travel->id = 100;
$this->pickupLoader
->expects($this->once())
->method('patchPickupsDetails')
->with($travel);
$this->hotelLoader
->expects($this->once())
->method('patchHotelDetails')
->with($travel);
$this->assertTrue($this->service->enrichFromXml($travel));
}
public function testEnrichFromXmlReturnsFalseAndLogsWarningOnFailure(): void
{
$travel = new Travel();
$travel->id = 100;
$this->pickupLoader
->method('patchPickupsDetails')
->willThrowException(new \RuntimeException('XML error'));
$this->logger
->expects($this->once())
->method('warning')
->with('Failed to enrich travel data', $this->arrayHasKey('travelId'));
$this->assertFalse($this->service->enrichFromXml($travel));
}
// -------------------------------------------------------------------------
// patchInsurances
// -------------------------------------------------------------------------
public function testPatchInsurancesReplacesExistingInsurances(): void
{
$travel = new Travel();
$travel->id = 100;
$stale = new Insurance();
$stale->id = 'stale';
$travel->insurances = [$stale];
$individual = new Insurance();
$individual->id = '10';
$individual->package = false;
$package = new Insurance();
$package->id = '20';
$package->package = true;
$package->containedInsuranceIds = ['10'];
// InsuranceParser populates containedInsurances at parse time
$package->containedInsurances = [$individual];
$this->insuranceLoader
->expects($this->once())
->method('loadAll')
->willReturn([$individual, $package]);
$this->service->patchInsurances($travel);
$this->assertSame([$individual, $package], $travel->insurances);
}
public function testPatchInsurancesPreservesContainedInsurancesFromParser(): void
{
$travel = new Travel();
$travel->id = 100;
$individual = new Insurance();
$individual->id = '10';
$individual->package = false;
$package = new Insurance();
$package->id = '20';
$package->package = true;
$package->containedInsuranceIds = ['10'];
$package->containedInsurances = [$individual];
$this->insuranceLoader->method('loadAll')->willReturn([$individual, $package]);
$this->service->patchInsurances($travel);
$this->assertCount(1, $package->containedInsurances);
$this->assertSame($individual, $package->containedInsurances[0]);
}
public function testPatchInsurancesLogsWarningOnFailureAndDoesNotThrow(): void
{
$travel = new Travel();
$travel->id = 100;
$original = [];
$travel->insurances = $original;
$this->insuranceLoader
->method('loadAll')
->willThrowException(new \RuntimeException('XML parse error'));
$this->logger
->expects($this->once())
->method('warning')
->with('Failed to load insurance data', $this->arrayHasKey('travelId'));
// Must not throw; insurances stay unchanged
$this->service->patchInsurances($travel);
$this->assertSame($original, $travel->insurances);
}
}
+297
View File
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Service\TravelLookupService;
use App\Service\TravelSnapshotService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class TravelLookupServiceTest extends TestCase
{
private TravelLookupService $service;
private TravelLoader $travelLoader;
private HotelLoader $hotelLoader;
private TravelSnapshotService $travelSnapshotService;
private LoggerInterface $logger;
protected function setUp(): void
{
$this->travelLoader = $this->createMock(TravelLoader::class);
$this->hotelLoader = $this->createMock(HotelLoader::class);
$this->travelSnapshotService = $this->createMock(TravelSnapshotService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->service = new TravelLookupService(
$this->travelLoader,
$this->hotelLoader,
$this->travelSnapshotService,
$this->logger,
);
}
// -------------------------------------------------------------------------
// generateFilesMap
// -------------------------------------------------------------------------
public function testGenerateFilesMapMergesXmlAndSnapshotMappings(): void
{
$dateId = 100;
$hotelId = 200;
$snapshotOnlyDateId = 300;
$xmlMapping = [
$dateId => ['id' => $dateId, 'code' => 'ABC', 'hotels' => [
$hotelId => ['id' => $hotelId],
]],
];
$snapshotMapping = [
$dateId => ['id' => $dateId, 'code' => 'ABC', 'hotels' => [
999 => ['id' => 999], // extra hotel only in snapshot
]],
$snapshotOnlyDateId => ['id' => $snapshotOnlyDateId, 'code' => 'XYZ', 'hotels' => []],
];
$this->travelLoader->method('generateFilesMap')->willReturn($xmlMapping);
$this->travelSnapshotService->method('generateMapping')->willReturn($snapshotMapping);
$result = $this->service->generateFilesMap();
// XML entry is preserved
$this->assertArrayHasKey($dateId, $result);
// Snapshot-only date is merged in
$this->assertArrayHasKey($snapshotOnlyDateId, $result);
// Extra hotel from snapshot is added to the XML date entry
$this->assertArrayHasKey(999, $result[$dateId]['hotels']);
// Original XML hotel is still present
$this->assertArrayHasKey($hotelId, $result[$dateId]['hotels']);
}
public function testGenerateFilesMapIsMemoizedWithinRequest(): void
{
$mapping = [100 => ['id' => 100, 'code' => 'A', 'hotels' => []]];
$this->travelLoader
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$this->travelSnapshotService
->expects($this->once())
->method('generateMapping')
->willReturn([]);
// Two calls — loaders invoked only once
$this->service->generateFilesMap();
$result = $this->service->generateFilesMap();
$this->assertSame($mapping, $result);
}
public function testGenerateFilesMapFallsBackToSnapshotsOnXmlFailure(): void
{
$snapshotMapping = [100 => ['id' => 100, 'code' => 'A', 'hotels' => []]];
$this->travelLoader
->method('generateFilesMap')
->willThrowException(new \RuntimeException('Filesystem error'));
$this->travelSnapshotService
->method('generateMapping')
->willReturn($snapshotMapping);
$this->logger
->expects($this->once())
->method('warning')
->with('Failed to generate XML files mapping, fallback to snapshots only', $this->arrayHasKey('error'));
$result = $this->service->generateFilesMap();
$this->assertSame($snapshotMapping, $result);
}
// -------------------------------------------------------------------------
// mapDateCodeToId
// -------------------------------------------------------------------------
public function testMapDateCodeToIdReturnsMatchingId(): void
{
$this->travelLoader->method('generateFilesMap')->willReturn([
42 => ['id' => 42, 'code' => 'WI25', 'hotels' => []],
]);
$this->travelSnapshotService->method('generateMapping')->willReturn([]);
$result = $this->service->mapDateCodeToId('WI25');
$this->assertSame(42, $result);
}
public function testMapDateCodeToIdReturnsNullWhenNotFound(): void
{
$this->travelLoader->method('generateFilesMap')->willReturn([]);
$this->travelSnapshotService->method('generateMapping')->willReturn([]);
$result = $this->service->mapDateCodeToId('UNKNOWN');
$this->assertNull($result);
}
// -------------------------------------------------------------------------
// mapHotelCodeToId
// -------------------------------------------------------------------------
public function testMapHotelCodeToIdDelegatesToHotelLoader(): void
{
$this->hotelLoader
->expects($this->once())
->method('mapCodeToId')
->with('HTL001')
->willReturn(77);
$result = $this->service->mapHotelCodeToId('HTL001');
$this->assertSame(77, $result);
}
public function testMapHotelCodeToIdReturnsNullOnException(): void
{
$this->hotelLoader
->method('mapCodeToId')
->willThrowException(new \RuntimeException('Not found'));
$result = $this->service->mapHotelCodeToId('MISSING');
$this->assertNull($result);
}
// -------------------------------------------------------------------------
// mapDateIdToProductId
// -------------------------------------------------------------------------
public function testMapDateIdToProductIdPrefersSnapshot(): void
{
$this->travelSnapshotService
->expects($this->once())
->method('findProductIdByDateId')
->with(100)
->willReturn(999);
$this->travelLoader
->expects($this->never())
->method('mapDateIdToProductId');
$result = $this->service->mapDateIdToProductId(100);
$this->assertSame(999, $result);
}
public function testMapDateIdToProductIdFallsBackToLoaderWhenSnapshotReturnsNull(): void
{
$this->travelSnapshotService
->expects($this->once())
->method('findProductIdByDateId')
->with(100)
->willReturn(null);
$this->travelLoader
->expects($this->once())
->method('mapDateIdToProductId')
->with(100)
->willReturn(42);
$result = $this->service->mapDateIdToProductId(100);
$this->assertSame(42, $result);
}
// -------------------------------------------------------------------------
// existsLocally
// -------------------------------------------------------------------------
public function testExistsLocallyTrueFromSnapshot(): void
{
$dateId = 12345;
$hotelId = 67890;
$this->travelSnapshotService
->expects($this->once())
->method('exists')
->with($dateId, $hotelId)
->willReturn(true);
$this->travelLoader
->expects($this->never())
->method('generateFilesMap');
$this->assertTrue($this->service->existsLocally($dateId, $hotelId));
}
public function testExistsLocallyTrueFromXmlMap(): void
{
$dateId = 12345;
$hotelId = 67890;
$mapping = [
$dateId => [
'id' => $dateId,
'hotels' => [$hotelId => ['id' => $hotelId]],
],
];
$this->travelSnapshotService->method('exists')->willReturn(false);
$this->travelLoader->method('generateFilesMap')->willReturn($mapping);
$this->travelSnapshotService->method('generateMapping')->willReturn([]);
$this->assertTrue($this->service->existsLocally($dateId, $hotelId));
}
public function testExistsLocallyFalseNoTravel(): void
{
$this->travelSnapshotService->method('exists')->willReturn(false);
$this->travelLoader->method('generateFilesMap')->willReturn([]);
$this->travelSnapshotService->method('generateMapping')->willReturn([]);
$this->assertFalse($this->service->existsLocally(12345, 67890));
}
public function testExistsLocallyFalseNoHotel(): void
{
$dateId = 12345;
$hotelId = 67890;
$mapping = [
$dateId => ['id' => $dateId, 'hotels' => []],
];
$this->travelSnapshotService->method('exists')->willReturn(false);
$this->travelLoader->method('generateFilesMap')->willReturn($mapping);
$this->travelSnapshotService->method('generateMapping')->willReturn([]);
$this->assertFalse($this->service->existsLocally($dateId, $hotelId));
}
// -------------------------------------------------------------------------
// getAvailableSources
// -------------------------------------------------------------------------
public function testGetAvailableSourcesReturnsBothFlags(): void
{
$dateId = 12345;
$hotelId = 67890;
$mapping = [
$dateId => ['id' => $dateId, 'hotels' => [$hotelId => []]],
];
$this->travelSnapshotService->method('exists')->willReturn(false);
$this->travelSnapshotService->method('generateMapping')->willReturn([]);
$this->travelSnapshotService->method('findProductIdByDateId')->willReturn(null);
$this->travelLoader->method('generateFilesMap')->willReturn($mapping);
$this->travelLoader->method('mapDateIdToProductId')->with($dateId)->willReturn(555);
$result = $this->service->getAvailableSources($dateId, $hotelId);
$this->assertEquals(['local' => true, 'remote' => true], $result);
}
}