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(500, false, 360, []); $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(500, false, 360, []); $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(500, false, 360, []); $this->assertSame(['processed' => 1, 'updated' => 0, 'failed' => 1], $result); } public function testRefreshSkipsAllCandidatesWhenXmlDateIdsIsNull(): void { $payload = '{"id":100}'; $snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload)); $this->snapshotRepository ->expects($this->once()) ->method('findRefreshCandidates') ->willReturn([$snapshot]); $this->apiClient ->expects($this->never()) ->method('getAvailabilitiesExtended'); $this->entityManager ->expects($this->never()) ->method('flush'); $result = $this->service->refreshExtendedSnapshots(500, false, 360, null); $this->assertSame(['processed' => 0, 'updated' => 0, 'failed' => 0], $result); } public function testRefreshSkipsSnapshotsWithXmlAvailable(): void { $payload = '{"id":100}'; $snapshot = new TravelSnapshot(100, 200, $payload, hash('sha256', $payload)); $this->snapshotRepository ->expects($this->once()) ->method('findRefreshCandidates') ->willReturn([$snapshot]); $this->apiClient ->expects($this->never()) ->method('getAvailabilitiesExtended'); $result = $this->service->refreshExtendedSnapshots(500, false, 360, [100]); $this->assertSame(['processed' => 0, 'updated' => 0, 'failed' => 0], $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']); } }