feat: persist travel snapshots and refresh extended availability
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\XmlParser\ExtendedAvailabilitiesParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class ExtendedAvailabilitiesParserTest extends TestCase
|
||||
{
|
||||
private ExtendedAvailabilitiesParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new ExtendedAvailabilitiesParser();
|
||||
}
|
||||
|
||||
public function testParsesServicesWithAllAttributes(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen>
|
||||
<leistung id="101" status="Frei" frei="8" preis="29,90" termin="01.01.2030" bis="06.01.2030"
|
||||
uhrzeit_von="09:00" hinweis="Bitte anmelden" altervon="10" alterbis="65" pflicht="True" />
|
||||
</leistungen>
|
||||
<reise status="Frei" buchungstatusmoeglich="FA" />
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xml);
|
||||
$response = $this->parser->parseServices($crawler);
|
||||
|
||||
$services = $response->getServices();
|
||||
$this->assertArrayHasKey(101, $services);
|
||||
|
||||
$availability = $services[101];
|
||||
$this->assertSame(101, $availability->serviceId);
|
||||
$this->assertSame('Frei', $availability->status);
|
||||
$this->assertSame(8, $availability->available);
|
||||
$this->assertSame(29.90, $availability->price);
|
||||
$this->assertSame('2030-01-01', $availability->dateFrom->format('Y-m-d'));
|
||||
$this->assertSame('2030-01-06', $availability->dateTo->format('Y-m-d'));
|
||||
$this->assertSame('09:00', $availability->timeFrom);
|
||||
$this->assertSame('Bitte anmelden', $availability->description);
|
||||
$this->assertSame(10, $availability->ageFrom);
|
||||
$this->assertSame(65, $availability->ageTo);
|
||||
$this->assertTrue($availability->mandatory);
|
||||
}
|
||||
|
||||
public function testIgnoresEmptyAgeAndMandatoryAttributes(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen>
|
||||
<leistung id="102" status="Frei" frei="0" preis="0,00" termin="01.01.2030" bis="06.01.2030"
|
||||
altervon="" alterbis="" pflicht="" />
|
||||
</leistungen>
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xml);
|
||||
$response = $this->parser->parseServices($crawler);
|
||||
|
||||
$services = $response->getServices();
|
||||
$this->assertArrayHasKey(102, $services);
|
||||
|
||||
$availability = $services[102];
|
||||
$this->assertNull($availability->ageFrom);
|
||||
$this->assertNull($availability->ageTo);
|
||||
$this->assertNull($availability->mandatory);
|
||||
}
|
||||
|
||||
public function testParsesTravelLevelMetadata(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen />
|
||||
<reise status="Frei" buchungstatusmoeglich="FA" />
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xml);
|
||||
$response = $this->parser->parseServices($crawler);
|
||||
|
||||
$this->assertSame('Frei', $response->travelStatus);
|
||||
$this->assertSame(['F', 'A'], $response->allowedBookingStatus);
|
||||
}
|
||||
|
||||
public function testEmptyBuchungstatusmoeglichProducesNoStatusCodes(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen />
|
||||
<reise status="Frei" buchungstatusmoeglich="" />
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xml);
|
||||
$response = $this->parser->parseServices($crawler);
|
||||
|
||||
$this->assertSame([], $response->allowedBookingStatus);
|
||||
}
|
||||
|
||||
public function testMissingReiseNodeLeavesMetadataNullAndEmpty(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen />
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xml);
|
||||
$response = $this->parser->parseServices($crawler);
|
||||
|
||||
$this->assertNull($response->travelStatus);
|
||||
$this->assertSame([], $response->allowedBookingStatus);
|
||||
}
|
||||
|
||||
public function testEmptyServicesCollection(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<leistungen />
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xml);
|
||||
$response = $this->parser->parseServices($crawler);
|
||||
|
||||
$this->assertSame([], $response->getServices());
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,32 @@ class TravelParserTest extends TestCase
|
||||
$this->assertSame('Bus-Hinfahrt', $busService->label);
|
||||
$this->assertSame('Bus fährt nur bei ausreichender Teilnehmerzahl', $busService->description);
|
||||
$this->assertFalse($busService->autoBook);
|
||||
|
||||
$this->assertSame(2187, $travel->productId);
|
||||
}
|
||||
|
||||
public function testProductIdIsNullWhenAttributeAbsent(): void
|
||||
{
|
||||
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<reisen>
|
||||
<reise id="1" idbuspro="2187" code="SSTMR">
|
||||
<termin id="1" idbuspro="11946" termin="01.01.2030" bis="06.01.2030" reiseart="F">
|
||||
<text>Test Travel</text>
|
||||
<abpreis>659,00</abpreis>
|
||||
<hotel id="1" idbuspro="157047">
|
||||
<zimmer>
|
||||
<preis zimmercode="4erDW" idbuspro_zimmer="95" zimmertext="4er Zimmer" MinPax="4" MaxPax="4" naechte="5" preis="689,00" status="Frei" verfuegbar="8" />
|
||||
</zimmer>
|
||||
</hotel>
|
||||
</termin>
|
||||
</reise>
|
||||
</reisen>';
|
||||
|
||||
$crawler = new Crawler($xmlContent);
|
||||
$travelNode = $crawler->filterXPath('//reise/termin')->first();
|
||||
$travel = $this->parser->parse($travelNode);
|
||||
|
||||
$this->assertNull($travel->productId);
|
||||
}
|
||||
|
||||
public function testParseServiceWithoutDescriptions(): void
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Entity\TravelSnapshot;
|
||||
use App\Repository\TravelSnapshotRepository;
|
||||
use App\Service\TravelSnapshotService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class TravelSnapshotServiceTest extends TestCase
|
||||
{
|
||||
private TravelSnapshotRepository $snapshotRepository;
|
||||
private EntityManagerInterface $entityManager;
|
||||
private ApiClient $apiClient;
|
||||
private LoggerInterface $logger;
|
||||
private SerializerInterface $serializer;
|
||||
private TravelSnapshotService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->snapshotRepository = $this->createMock(TravelSnapshotRepository::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->apiClient = $this->createMock(ApiClient::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->serializer = $this->createMock(SerializerInterface::class);
|
||||
|
||||
$this->service = new TravelSnapshotService(
|
||||
$this->snapshotRepository,
|
||||
$this->entityManager,
|
||||
$this->apiClient,
|
||||
$this->logger,
|
||||
$this->serializer,
|
||||
14,
|
||||
);
|
||||
}
|
||||
|
||||
public function testUpsertCreatesNewSnapshot(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 100;
|
||||
$travel->hotelId = 200;
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findByDateAndHotel')
|
||||
->with(100, 200)
|
||||
->willReturn(null);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('serialize')
|
||||
->with($travel, 'json')
|
||||
->willReturn('{"id":100}');
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->once())
|
||||
->method('persist')
|
||||
->with($this->isInstanceOf(TravelSnapshot::class));
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->once())
|
||||
->method('flush');
|
||||
|
||||
$this->service->upsertFromTravel($travel);
|
||||
}
|
||||
|
||||
public function testUpsertSkipsWhenHashUnchanged(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 100;
|
||||
$travel->hotelId = 200;
|
||||
|
||||
$payload = '{"id":100}';
|
||||
$hash = hash('sha256', $payload);
|
||||
$snapshot = new TravelSnapshot(100, 200, $payload, $hash);
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findByDateAndHotel')
|
||||
->with(100, 200)
|
||||
->willReturn($snapshot);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('serialize')
|
||||
->with($travel, 'json')
|
||||
->willReturn($payload);
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->never())
|
||||
->method('flush');
|
||||
|
||||
$this->service->upsertFromTravel($travel);
|
||||
}
|
||||
|
||||
public function testUpsertUpdatesWhenHashChanged(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 100;
|
||||
$travel->hotelId = 200;
|
||||
|
||||
$oldPayload = '{"id":100,"old":true}';
|
||||
$oldHash = hash('sha256', $oldPayload);
|
||||
$snapshot = new TravelSnapshot(100, 200, $oldPayload, $oldHash);
|
||||
|
||||
$newPayload = '{"id":100,"old":false}';
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findByDateAndHotel')
|
||||
->with(100, 200)
|
||||
->willReturn($snapshot);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('serialize')
|
||||
->with($travel, 'json')
|
||||
->willReturn($newPayload);
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->once())
|
||||
->method('flush');
|
||||
|
||||
$this->service->upsertFromTravel($travel);
|
||||
}
|
||||
|
||||
public function testLoadTravelReturnsNullWhenNoSnapshot(): void
|
||||
{
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findByDateAndHotel')
|
||||
->with(100, 200)
|
||||
->willReturn(null);
|
||||
|
||||
$result = $this->service->loadTravel(100, 200);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testLoadTravelReturnsNullOnDeserializationError(): void
|
||||
{
|
||||
$snapshot = new TravelSnapshot(100, 200, 'invalid-json', hash('sha256', 'invalid-json'));
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findByDateAndHotel')
|
||||
->with(100, 200)
|
||||
->willReturn($snapshot);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('deserialize')
|
||||
->willThrowException(new \RuntimeException('Deserialization failed'));
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Snapshot payload cannot be deserialized to Travel', $this->arrayHasKey('snapshotId'));
|
||||
|
||||
$result = $this->service->loadTravel(100, 200);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testExistsReturnsTrueWhenSnapshotFound(): void
|
||||
{
|
||||
$snapshot = new TravelSnapshot(100, 200, '{}', hash('sha256', '{}'));
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findByDateAndHotel')
|
||||
->with(100, 200)
|
||||
->willReturn($snapshot);
|
||||
|
||||
$this->assertTrue($this->service->exists(100, 200));
|
||||
}
|
||||
|
||||
public function testExistsReturnsFalseWhenNoSnapshot(): void
|
||||
{
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findByDateAndHotel')
|
||||
->with(100, 200)
|
||||
->willReturn(null);
|
||||
|
||||
$this->assertFalse($this->service->exists(100, 200));
|
||||
}
|
||||
|
||||
public function testLoadTravelReturnsDeserializedTravel(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 100;
|
||||
$travel->hotelId = 200;
|
||||
|
||||
$payload = '{"id":100}';
|
||||
$snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload));
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findByDateAndHotel')
|
||||
->with(100, 200)
|
||||
->willReturn($snapshot);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('deserialize')
|
||||
->with($payload, Travel::class, 'json')
|
||||
->willReturn($travel);
|
||||
|
||||
$result = $this->service->loadTravel(100, 200);
|
||||
|
||||
$this->assertInstanceOf(Travel::class, $result);
|
||||
$this->assertSame($travel, $result);
|
||||
}
|
||||
|
||||
public function testLoadTravelReturnsNullWhenDeserializerReturnsWrongType(): void
|
||||
{
|
||||
$payload = '{"id":100}';
|
||||
$snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload));
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findByDateAndHotel')
|
||||
->with(100, 200)
|
||||
->willReturn($snapshot);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('deserialize')
|
||||
->willReturn(new \stdClass());
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Snapshot payload deserialization returned unexpected type', $this->arrayHasKey('snapshotId'));
|
||||
|
||||
$result = $this->service->loadTravel(100, 200);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testUpsertFromTravelSkipsWhenTravelIsIncomplete(): void
|
||||
{
|
||||
$travelNoId = new Travel();
|
||||
$travelNoId->id = null;
|
||||
$travelNoId->hotelId = 200;
|
||||
|
||||
$travelNoHotel = new Travel();
|
||||
$travelNoHotel->id = 100;
|
||||
$travelNoHotel->hotelId = null;
|
||||
|
||||
$this->serializer->expects($this->never())->method('serialize');
|
||||
$this->entityManager->expects($this->never())->method('flush');
|
||||
|
||||
$this->service->upsertFromTravel($travelNoId);
|
||||
$this->service->upsertFromTravel($travelNoHotel);
|
||||
}
|
||||
|
||||
public function testFindProductIdByDateIdDelegatesToRepository(): void
|
||||
{
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findProductIdByDateId')
|
||||
->with(42)
|
||||
->willReturn(365);
|
||||
|
||||
$result = $this->service->findProductIdByDateId(42);
|
||||
|
||||
$this->assertSame(365, $result);
|
||||
}
|
||||
|
||||
public function testPurgeExpiredSnapshotsDelegatesWithCorrectDate(): void
|
||||
{
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('deleteExpiredSnapshots')
|
||||
->with($this->callback(function (\DateTimeImmutable $date) {
|
||||
$expected = new \DateTimeImmutable('-14 days');
|
||||
|
||||
return abs($date->getTimestamp() - $expected->getTimestamp()) < 5;
|
||||
}))
|
||||
->willReturn(7);
|
||||
|
||||
$result = $this->service->purgeExpiredSnapshots();
|
||||
|
||||
$this->assertSame(7, $result);
|
||||
}
|
||||
|
||||
public function testRefreshExtendedSnapshotsSuccessPath(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 100;
|
||||
$travel->hotelId = 200;
|
||||
|
||||
$oldPayload = '{"id":100}';
|
||||
$oldHash = hash('sha256', $oldPayload);
|
||||
$snapshot = new TravelSnapshot(100, 200, $oldPayload, $oldHash);
|
||||
|
||||
$newPayload = '{"id":100,"extended":true}';
|
||||
$response = new ExtendedServiceAvailabilityResponse([], [], null);
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findRefreshCandidates')
|
||||
->willReturn([$snapshot]);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('deserialize')
|
||||
->with($oldPayload, Travel::class, 'json')
|
||||
->willReturn($travel);
|
||||
|
||||
$this->apiClient
|
||||
->expects($this->once())
|
||||
->method('getAvailabilitiesExtended')
|
||||
->with(100)
|
||||
->willReturn($response);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('serialize')
|
||||
->with($travel, 'json')
|
||||
->willReturn($newPayload);
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->once())
|
||||
->method('flush');
|
||||
|
||||
$result = $this->service->refreshExtendedSnapshots();
|
||||
|
||||
$this->assertSame(['processed' => 1, 'updated' => 1, 'failed' => 0], $result);
|
||||
}
|
||||
|
||||
public function testRefreshExtendedSnapshotsApiFailureIncrementsFailedCount(): void
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 100;
|
||||
$travel->hotelId = 200;
|
||||
|
||||
$payload = '{"id":100}';
|
||||
$snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload));
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findRefreshCandidates')
|
||||
->willReturn([$snapshot]);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('deserialize')
|
||||
->willReturn($travel);
|
||||
|
||||
$this->apiClient
|
||||
->expects($this->once())
|
||||
->method('getAvailabilitiesExtended')
|
||||
->willThrowException(new ApiClientException('API error'));
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Failed to fetch extended availability', $this->arrayHasKey('dateId'));
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->never())
|
||||
->method('flush');
|
||||
|
||||
$result = $this->service->refreshExtendedSnapshots();
|
||||
|
||||
$this->assertSame(['processed' => 1, 'updated' => 0, 'failed' => 1], $result);
|
||||
}
|
||||
|
||||
public function testRefreshExtendedSnapshotsDeserializationFailureIncrementsFailedCount(): void
|
||||
{
|
||||
$payload = '{"id":100}';
|
||||
$snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload));
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findRefreshCandidates')
|
||||
->willReturn([$snapshot]);
|
||||
|
||||
$this->serializer
|
||||
->expects($this->once())
|
||||
->method('deserialize')
|
||||
->willThrowException(new \RuntimeException('Deserialization failed'));
|
||||
|
||||
$this->logger
|
||||
->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Snapshot payload cannot be deserialized to Travel', $this->arrayHasKey('snapshotId'));
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->never())
|
||||
->method('flush');
|
||||
|
||||
$result = $this->service->refreshExtendedSnapshots();
|
||||
|
||||
$this->assertSame(['processed' => 1, 'updated' => 0, 'failed' => 1], $result);
|
||||
}
|
||||
|
||||
public function testGenerateMappingBuildsCorrectStructure(): void
|
||||
{
|
||||
$hotel = new Hotel();
|
||||
$hotel->code = 'HTL1';
|
||||
$hotel->name = 'Hotel One';
|
||||
|
||||
$travel1 = new Travel();
|
||||
$travel1->id = 10;
|
||||
$travel1->hotelId = 20;
|
||||
$travel1->code = 'TRIP1';
|
||||
$travel1->label = 'Trip One';
|
||||
$travel1->productId = 365;
|
||||
$travel1->hotel = $hotel;
|
||||
$travel1->dateFrom = new \DateTimeImmutable('2026-06-01');
|
||||
$travel1->dateTo = new \DateTimeImmutable('2026-06-14');
|
||||
|
||||
$payload1 = '{"id":10}';
|
||||
$snapshot1 = new TravelSnapshot(10, 20, $payload1, hash('sha256', $payload1));
|
||||
$snapshot1->setDateCode('TRIP1')
|
||||
->setLabel('Trip One')
|
||||
->setDateFrom(new \DateTimeImmutable('2026-06-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-06-14'))
|
||||
->setHotelCode('HTL1')
|
||||
->setHotelLabel('Hotel One');
|
||||
|
||||
$payload2 = '{"id":10,"h":21}';
|
||||
$snapshot2 = new TravelSnapshot(10, 21, $payload2, hash('sha256', $payload2));
|
||||
$snapshot2->setDateCode('TRIP1')
|
||||
->setLabel('Trip One')
|
||||
->setDateFrom(new \DateTimeImmutable('2026-06-01'))
|
||||
->setDateTo(new \DateTimeImmutable('2026-06-14'))
|
||||
->setHotelCode('HTL2')
|
||||
->setHotelLabel('Hotel Two');
|
||||
|
||||
$this->snapshotRepository
|
||||
->expects($this->once())
|
||||
->method('findAllForMapping')
|
||||
->willReturn([$snapshot1, $snapshot2]);
|
||||
|
||||
$mapping = $this->service->generateMapping();
|
||||
|
||||
$this->assertArrayHasKey(10, $mapping);
|
||||
$this->assertSame('TRIP1', $mapping[10]['code']);
|
||||
$this->assertArrayHasKey(20, $mapping[10]['hotels']);
|
||||
$this->assertArrayHasKey(21, $mapping[10]['hotels']);
|
||||
$this->assertSame('HTL1', $mapping[10]['hotels'][20]['code']);
|
||||
$this->assertSame('HTL2', $mapping[10]['hotels'][21]['code']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user