460 lines
14 KiB
PHP
460 lines
14 KiB
PHP
<?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']);
|
|
}
|
|
}
|