feat: persist travel snapshots and refresh extended availability

This commit is contained in:
Björn Fromme
2026-03-23 17:29:10 +01:00
parent c1ab6d8687
commit bc7fb794bf
31 changed files with 2328 additions and 91 deletions
+262 -22
View File
@@ -5,6 +5,7 @@ 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;
@@ -12,10 +13,11 @@ use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Exception\TravelNotFoundException;
use App\Service\TravelDataService;
use App\Service\TravelSnapshotService;
use Flagception\Manager\FeatureManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class TravelDataServiceTest extends TestCase
{
@@ -27,6 +29,8 @@ class TravelDataServiceTest extends TestCase
private ApiClient $apiClient;
private CacheInterface $cache;
private LoggerInterface $logger;
private TravelSnapshotService $travelSnapshotService;
private FeatureManagerInterface $featureManager;
protected function setUp(): void
{
@@ -37,6 +41,9 @@ class TravelDataServiceTest extends TestCase
$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->featureManager = $this->createMock(FeatureManagerInterface::class);
$this->featureManager->method('isActive')->with('travel_snapshot')->willReturn(true);
$this->service = new TravelDataService(
$this->travelLoader,
@@ -46,8 +53,10 @@ class TravelDataServiceTest extends TestCase
$this->apiClient,
$this->cache,
$this->logger,
$this->travelSnapshotService,
$this->featureManager,
false, // preferRemote
true // enableFallback
true, // enableFallback
);
}
@@ -149,7 +158,27 @@ class TravelDataServiceTest extends TestCase
$this->assertNull($result);
}
public function testExistsInXmlTrue(): void
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');
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertTrue($result);
}
public function testExistsLocallyTrueFromXmlMap(): void
{
$dateId = 12345;
$hotelId = 67890;
@@ -162,33 +191,45 @@ class TravelDataServiceTest extends TestCase
],
];
$this->travelSnapshotService
->expects($this->once())
->method('exists')
->with($dateId, $hotelId)
->willReturn(false);
$this->travelLoader
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$result = $this->service->existsInXml($dateId, $hotelId);
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertTrue($result);
}
public function testExistsInXmlFalseNoTravel(): void
public function testExistsLocallyFalseNoTravel(): void
{
$dateId = 12345;
$hotelId = 67890;
$mapping = [];
$this->travelSnapshotService
->expects($this->once())
->method('exists')
->with($dateId, $hotelId)
->willReturn(false);
$this->travelLoader
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$result = $this->service->existsInXml($dateId, $hotelId);
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertFalse($result);
}
public function testExistsInXmlFalseNoHotel(): void
public function testExistsLocallyFalseNoHotel(): void
{
$dateId = 12345;
$hotelId = 67890;
@@ -199,12 +240,18 @@ class TravelDataServiceTest extends TestCase
],
];
$this->travelSnapshotService
->expects($this->once())
->method('exists')
->with($dateId, $hotelId)
->willReturn(false);
$this->travelLoader
->expects($this->once())
->method('generateFilesMap')
->willReturn($mapping);
$result = $this->service->existsInXml($dateId, $hotelId);
$result = $this->service->existsLocally($dateId, $hotelId);
$this->assertFalse($result);
}
@@ -223,6 +270,12 @@ class TravelDataServiceTest extends TestCase
],
];
$this->travelSnapshotService
->expects($this->once())
->method('exists')
->with($dateId, $hotelId)
->willReturn(false);
$this->travelLoader
->expects($this->once())
->method('generateFilesMap')
@@ -242,7 +295,7 @@ class TravelDataServiceTest extends TestCase
], $result);
}
public function testGetTravelDataWithCaching(): void
public function testGetTravelDataFromLocalPrefersSnapshot(): void
{
$dateId = 12345;
$hotelId = 67890;
@@ -250,19 +303,69 @@ class TravelDataServiceTest extends TestCase
$travel->id = $dateId;
$travel->hotelId = $hotelId;
$cacheItem = $this->createMock(ItemInterface::class);
$cacheItem
$this->travelSnapshotService
->expects($this->once())
->method('expiresAfter')
->with(300);
->method('loadTravel')
->with($dateId, $hotelId)
->willReturn($travel);
$this->cache
$this->travelLoader
->expects($this->never())
->method('loadById');
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
$this->assertSame($travel, $result);
}
public function testGetTravelDataFromLocalRehydratesInsurancePackagesFromSnapshot(): void
{
$dateId = 12345;
$hotelId = 67890;
// Simulate a deserialized snapshot state: containedInsuranceIds are present but
// containedInsurances is empty (circular-reference prevention strips it during serialization).
$individual = new Insurance();
$individual->id = '10';
$individual->package = false;
$package = new Insurance();
$package->id = '20';
$package->package = true;
$package->containedInsuranceIds = ['10'];
$package->containedInsurances = []; // as it arrives after deserialization
$travel = new Travel();
$travel->id = $dateId;
$travel->hotelId = $hotelId;
$travel->insurances = [$individual, $package];
$this->travelSnapshotService
->expects($this->once())
->method('get')
->with('travel_unified_12345_67890_local')
->willReturnCallback(function (string $key, callable $callback) use ($cacheItem) {
return $callback($cacheItem);
});
->method('loadTravel')
->with($dateId, $hotelId)
->willReturn($travel);
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
$this->assertSame($travel, $result);
$this->assertCount(1, $package->containedInsurances);
$this->assertSame($individual, $package->containedInsurances[0]);
}
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())
@@ -280,12 +383,62 @@ class TravelDataServiceTest extends TestCase
->method('patchHotelDetails')
->with($travel);
$result = $this->service->getTravelData($dateId, $hotelId, false, true);
$this->travelSnapshotService
->expects($this->once())
->method('upsertFromTravel')
->with($travel);
$result = $this->service->getTravelDataFromLocal($dateId, $hotelId);
$this->assertSame($travel, $result);
}
public function testGetTravelDataWithoutCaching(): void
public function testGetTravelDataFromLocalRehydratesInsurancePackagesFromXmlFallback(): void
{
$dateId = 12345;
$hotelId = 67890;
// insuranceLoader->loadAll() returns these during enrichTravelData(); the package's
// containedInsurances starts empty (as it would from a fresh XML load before hydration).
$individual = new Insurance();
$individual->id = '10';
$individual->package = false;
$package = new Insurance();
$package->id = '20';
$package->package = true;
$package->containedInsuranceIds = ['10'];
$package->containedInsurances = [];
$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->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;
@@ -297,6 +450,12 @@ class TravelDataServiceTest extends TestCase
->expects($this->never())
->method('get');
$this->travelSnapshotService
->expects($this->once())
->method('loadTravel')
->with($dateId, $hotelId)
->willReturn(null);
$this->travelLoader
->expects($this->once())
->method('loadById')
@@ -313,8 +472,89 @@ class TravelDataServiceTest extends TestCase
->method('patchHotelDetails')
->with($travel);
$result = $this->service->getTravelData($dateId, $hotelId, false, false);
$result = $this->service->getTravelData($dateId, $hotelId, false);
$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->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);
}
}