travelLoader = $this->createMock(TravelLoader::class); $this->apiClient = $this->createMock(ApiClient::class); $this->cache = $this->createMock(CacheInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->travelSnapshotService = $this->createMock(TravelSnapshotManager::class); $this->travelLookupService = $this->createMock(TravelIndex::class); $this->travelEnrichmentService = $this->createMock(TravelEnricher::class); $this->service = new TravelDataProvider( $this->travelLoader, $this->apiClient, $this->cache, $this->logger, $this->travelSnapshotService, $this->travelLookupService, $this->travelEnrichmentService, false, // preferRemote true, // enableFallback ); } public function testGetTravelDataFromXmlSuccess(): void { $dateId = 12345; $hotelId = 67890; $travel = new Travel(); $travel->id = $dateId; $travel->hotelId = $hotelId; $this->travelLoader ->expects($this->once()) ->method('loadById') ->with($dateId, $hotelId) ->willReturn($travel); $this->travelEnrichmentService ->expects($this->once()) ->method('enrichFromXml') ->with($travel) ->willReturn(true); $this->travelSnapshotService ->expects($this->once()) ->method('upsertFromTravel') ->with($travel); $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('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); $this->assertSame($travel, $result); } public function testGetTravelDataFromXmlNotFound(): void { $dateId = 12345; $hotelId = 67890; $this->travelLoader ->expects($this->once()) ->method('loadById') ->with($dateId, $hotelId) ->willThrowException(new TravelNotFoundException($dateId)); $this->travelEnrichmentService ->expects($this->never()) ->method('enrichFromXml'); $this->expectException(TravelNotFoundException::class); $this->service->getTravelDataFromXml($dateId, $hotelId); } public function testGetTravelDataFromApiSuccess(): void { $dateId = 12345; $hotelId = 67890; $productId = 555; $travel = new Travel(); $travel->id = $dateId; $travel->hotelId = $hotelId; $this->travelLookupService ->expects($this->once()) ->method('mapDateIdToProductId') ->with($dateId) ->willReturn($productId); $this->apiClient ->expects($this->once()) ->method('getTravelData') ->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->travelLookupService ->expects($this->once()) ->method('mapDateIdToProductId') ->with($dateId) ->willReturn(null); $this->apiClient ->expects($this->never()) ->method('getTravelData'); $result = $this->service->getTravelDataFromApi($dateId, $hotelId); $this->assertNull($result); } public function testExistsLocallyDelegatesToLookupService(): void { $dateId = 12345; $hotelId = 67890; $this->travelLookupService ->expects($this->once()) ->method('existsLocally') ->with($dateId, $hotelId) ->willReturn(true); $this->assertTrue($this->service->existsLocally($dateId, $hotelId)); } public function testGetAvailableSourcesDelegatesToLookupService(): void { $dateId = 12345; $hotelId = 67890; $expected = ['local' => true, 'remote' => true]; $this->travelLookupService ->expects($this->once()) ->method('getAvailableSources') ->with($dateId, $hotelId) ->willReturn($expected); $this->assertSame($expected, $this->service->getAvailableSources($dateId, $hotelId)); } public function testGenerateFilesMapDelegatesToLookupService(): void { $mapping = [12345 => ['id' => 12345, 'hotels' => []]]; $this->travelLookupService ->expects($this->once()) ->method('generateFilesMap') ->willReturn($mapping); $this->assertSame($mapping, $this->service->generateFilesMap()); } public function testGetTravelDataFromLocalPrefersSnapshot(): void { $dateId = 12345; $hotelId = 67890; $travel = new Travel(); $travel->id = $dateId; $travel->hotelId = $hotelId; $this->travelSnapshotService ->expects($this->once()) ->method('loadTravel') ->with($dateId, $hotelId) ->willReturn($travel); $this->travelLoader ->expects($this->never()) ->method('loadById'); $this->travelEnrichmentService ->expects($this->once()) ->method('patchInsurances') ->with($travel); $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); $this->assertSame($travel, $result); } public function testGetTravelDataFromLocalFallsBackToXmlWhenSnapshotMissing(): void { $dateId = 12345; $hotelId = 67890; $travel = new Travel(); $travel->id = $dateId; $travel->hotelId = $hotelId; $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->travelEnrichmentService ->expects($this->once()) ->method('enrichFromXml') ->with($travel) ->willReturn(true); $this->travelSnapshotService ->expects($this->once()) ->method('upsertFromTravel') ->with($travel); $this->travelEnrichmentService ->expects($this->once()) ->method('patchInsurances') ->with($travel); $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); $this->assertSame($travel, $result); } public function testGetTravelDataFromLocalPropagatesTravelNotFoundFromXmlFallback(): void { $dateId = 12345; $hotelId = 67890; $this->travelSnapshotService ->expects($this->once()) ->method('loadTravel') ->with($dateId, $hotelId) ->willReturn(null); $this->travelLoader ->expects($this->once()) ->method('loadById') ->with($dateId, $hotelId) ->willThrowException(new TravelNotFoundException($dateId)); $this->expectException(TravelNotFoundException::class); $this->service->getTravelDataFromLocal($dateId, $hotelId); } public function testSnapshotLoadFailureFallsBackToXml(): void { $dateId = 12345; $hotelId = 67890; $travel = new Travel(); $travel->id = $dateId; $travel->hotelId = $hotelId; $this->travelSnapshotService ->expects($this->once()) ->method('loadTravel') ->with($dateId, $hotelId) ->willThrowException(new \RuntimeException('DB connection failed')); $this->logger ->expects($this->once()) ->method('warning') ->with('Snapshot lookup failed, falling back to XML', $this->arrayHasKey('dateId')); $this->travelLoader ->expects($this->once()) ->method('loadById') ->with($dateId, $hotelId) ->willReturn($travel); $result = $this->service->getTravelDataFromLocal($dateId, $hotelId); $this->assertSame($travel, $result); } public function testSnapshotPersistenceFailureDoesNotDiscardXmlTravel(): void { $dateId = 12345; $hotelId = 67890; $travel = new Travel(); $travel->id = $dateId; $travel->hotelId = $hotelId; $this->travelLoader ->expects($this->once()) ->method('loadById') ->with($dateId, $hotelId) ->willReturn($travel); $this->travelEnrichmentService ->method('enrichFromXml') ->willReturn(true); $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 XML load', $this->arrayHasKey('dateId')); $result = $this->service->getTravelDataFromXml($dateId, $hotelId); $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); } public function testEnrichWithFreshAvailabilitiesRefreshesRoomInventory(): void { $dateId = 12345; $hotelId = 67890; $travel = new Travel(); $travel->id = $dateId; $travel->hotelId = $hotelId; $travel->dateTo = new \DateTimeImmutable('2030-01-06'); $room1 = new Room(); $room1->id = 1; $room1->label = 'Doppelzimmer'; $room1->available = 2; $room1->status = 'Frei'; $room2 = new Room(); $room2->id = 2; $room2->label = 'Familienzimmer'; $room2->available = 1; $room2->status = 'Frei'; $travel->rooms = [$room1, $room2]; $liveRoom = new Room(); $liveRoom->id = 1; $liveRoom->available = 0; $liveRoom->status = 'Frei'; $liveRoom->price = 199.5; $liveRoom2 = new Room(); $liveRoom2->id = 2; $liveRoom2->available = 1; $liveRoom2->status = 'Frei'; $liveRoom2->price = 249.0; $this->apiClient ->expects($this->once()) ->method('getAvailabilities') ->with($dateId) ->willReturn(new ServiceAvailabilityResponse([])); $this->apiClient ->expects($this->once()) ->method('getHotelAvailability') ->with($dateId, $hotelId, $travel->dateTo) ->willReturn(new BaseData([ 1 => $liveRoom, 2 => $liveRoom2, ])); $this->travelLoader ->expects($this->once()) ->method('patchRoomAvailabilities') ->with($travel, $this->isInstanceOf(BaseData::class)) ->willReturnCallback(static function (Travel $travel, BaseData $roomAvailabilities): void { $liveRooms = $roomAvailabilities->getItems(); foreach ($travel->rooms as $room) { $liveRoom = $liveRooms[$room->id] ?? null; if (!($liveRoom instanceof Room)) { continue; } $room->available = $liveRoom->available; $room->price = $liveRoom->price; } }); $this->service->enrichWithFreshAvailabilities($travel, false, refreshRooms: true); $this->assertSame(0, $travel->rooms[0]->available); $this->assertSame(199.5, $travel->rooms[0]->price); $this->assertSame(1, $travel->rooms[1]->available); $this->assertSame(249.0, $travel->rooms[1]->price); $this->assertSame([2 => $travel->rooms[1]], $travel->getAvailableRooms()); } public function testPatchRoomAvailabilitiesLogsMissingRoomIdsAsContractIssue(): void { $travel = new Travel(); $travel->id = 12345; $travel->hotelId = 67890; $room1 = new Room(); $room1->id = 1; $room1->available = 2; $room1->status = 'Frei'; $room2 = new Room(); $room2->id = 2; $room2->available = 1; $room2->status = 'Frei'; $travel->rooms = [$room1, $room2]; $liveRoom = new Room(); $liveRoom->id = 1; $liveRoom->available = 0; $liveRoom->status = 'Frei'; $this->logger ->expects($this->once()) ->method('warning') ->with( 'Hotel availability response is missing room IDs from the travel snapshot', $this->callback(static function (array $context): bool { return [2] === $context['missingRoomIds'] && 12345 === $context['travelId'] && 67890 === $context['hotelId']; }) ); $this->travelLoader ->expects($this->once()) ->method('patchRoomAvailabilities') ->with($travel, $this->isInstanceOf(BaseData::class)); $this->service->patchRoomAvailabilities($travel, new BaseData([ 1 => $liveRoom, ])); } public function testEnrichWithFreshAvailabilitiesDoesNotForceRefreshHotelAvailabilityByDefault(): void { $travel = new Travel(); $travel->id = 12345; $travel->hotelId = 67890; $travel->dateTo = new \DateTimeImmutable('2030-01-06'); $this->cache ->expects($this->never()) ->method('delete'); $this->cache ->expects($this->exactly(2)) ->method('get') ->willReturnCallback(static fn (string $key, callable $callback) => $callback(new \Symfony\Component\Cache\CacheItem())); $this->apiClient ->expects($this->once()) ->method('getAvailabilities') ->with($travel->id) ->willReturn(new ServiceAvailabilityResponse([])); $this->apiClient ->expects($this->once()) ->method('getHotelAvailability') ->with($travel->id, $travel->hotelId, $travel->dateTo) ->willReturn(new BaseData([])); $this->service->enrichWithFreshAvailabilities($travel, refreshRooms: true); } public function testEnrichWithFreshAvailabilitiesDoesNotRefreshRoomsByDefault(): void { $travel = new Travel(); $travel->id = 12345; $travel->hotelId = 67890; $travel->dateTo = new \DateTimeImmutable('2030-01-06'); $this->cache ->expects($this->once()) ->method('get') ->willReturnCallback(static fn (string $key, callable $callback) => $callback(new \Symfony\Component\Cache\CacheItem())); $this->apiClient ->expects($this->once()) ->method('getAvailabilities') ->with($travel->id) ->willReturn(new ServiceAvailabilityResponse([])); $this->apiClient ->expects($this->never()) ->method('getHotelAvailability'); $this->travelLoader ->expects($this->never()) ->method('patchRoomAvailabilities'); $this->service->enrichWithFreshAvailabilities($travel); } public function testEnrichWithFreshAvailabilitiesForceRefreshInvalidatesHotelAvailabilityCache(): void { $travel = new Travel(); $travel->id = 12345; $travel->hotelId = 67890; $travel->dateTo = new \DateTimeImmutable('2030-01-06'); $this->cache ->expects($this->exactly(2)) ->method('delete') ->with($this->logicalOr( $this->equalTo('availability_12345'), $this->equalTo('hotel_availability_12345_67890_20300106') )); $this->cache ->expects($this->exactly(2)) ->method('get') ->willReturnCallback(static fn (string $key, callable $callback) => $callback(new \Symfony\Component\Cache\CacheItem())); $this->apiClient ->expects($this->once()) ->method('getAvailabilities') ->with($travel->id) ->willReturn(new ServiceAvailabilityResponse([])); $this->apiClient ->expects($this->once()) ->method('getHotelAvailability') ->with($travel->id, $travel->hotelId, $travel->dateTo) ->willReturn(new BaseData([])); $this->service->enrichWithFreshAvailabilities($travel, true, true, refreshRooms: true); } }