client = $this->createMock(ContingentsClient::class); $this->dayRepository = $this->createMock(ContingentDayRepository::class); $this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class); $this->entityManager = $this->createMock(EntityManagerInterface::class); } public function testAddsMissingDaysAndMarksSnapshotChanged(): void { $this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]); $this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK', '2026-07-02:BLOCKED']); $this->client->method('getContingentCalendar')->willReturn($this->response([ new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok), new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked), ])); $this->entityManager->expects(self::exactly(3))->method('persist'); $result = $this->sync(); self::assertTrue($result->successful); self::assertTrue($result->changed); self::assertSame(2, $result->added); self::assertSame(0, $result->updated); self::assertSame(0, $result->removed); } public function testUpdatesChangedStatusAndRemovesVanishedDays(): void { $existing = [ '2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok), '2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok), '2026-07-03' => $this->day('2026-07-03', ContingentStatus::Ok), ]; $this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing); $this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']); $this->client->method('getContingentCalendar')->willReturn($this->response([ new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok), new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest), ])); $this->entityManager->expects(self::once())->method('remove'); $result = $this->sync(); self::assertSame(0, $result->added); self::assertSame(1, $result->updated); self::assertSame(1, $result->removed); self::assertSame(ContingentStatus::OnRequest, $existing['2026-07-02']->getStatus()); } public function testUnchangedFingerprintDoesNotMoveChangedAt(): void { $state = new ContingentSyncState($this->accommodation()); $state->setContentHash(hash('sha256', '2026-07-01:OK')); $this->syncStateRepository->method('findOneByAccommodation')->willReturn($state); $this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([ '2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok), ]); $this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']); $this->client->method('getContingentCalendar')->willReturn($this->response([ new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok), ])); $result = $this->sync(); self::assertFalse($result->changed); self::assertNull($state->getChangedAt()); self::assertNotNull($state->getSyncedAt()); } public function testUpstreamFailureKeepsSnapshotAndRecordsError(): void { $state = new ContingentSyncState($this->accommodation()); $this->syncStateRepository->method('findOneByAccommodation')->willReturn($state); $this->client->method('getContingentCalendar')->willThrowException(new BpnConnectException('upstream down')); $this->dayRepository->expects(self::never())->method('findByAccommodationAndDateRange'); $this->entityManager->expects(self::never())->method('remove'); $result = $this->sync(); self::assertFalse($result->successful); self::assertSame('upstream down', $result->error); self::assertSame(1, $state->getFailureCount()); self::assertNull($state->getSyncedAt()); } public function testEmptyUpstreamResponseDoesNotWipeAPopulatedRange(): void { $state = new ContingentSyncState($this->accommodation()); $this->syncStateRepository->method('findOneByAccommodation')->willReturn($state); $this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([ '2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok), ]); $this->client->method('getContingentCalendar')->willReturn($this->response([])); $this->entityManager->expects(self::never())->method('remove'); $result = $this->sync(); self::assertFalse($result->successful); self::assertSame(1, $state->getFailureCount()); } public function testDaysOutsideTheRequestedWindowAreIgnored(): void { $this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]); $this->dayRepository->method('findStatusFingerprintParts')->willReturn([]); $this->client->method('getContingentCalendar')->willReturn($this->response([ new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok), new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok), new ContingentCalendarEntry('2026-08-01', ContingentStatus::Ok), ])); $result = $this->sync(); self::assertSame(1, $result->added); } public function testNearTermSyncDoesNotShrinkTheHorizonEstablishedByTheFullRun(): void { $state = new ContingentSyncState($this->accommodation()); $state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2028-08-20')); $this->syncStateRepository->method('findOneByAccommodation')->willReturn($state); $this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]); $this->dayRepository->method('findStatusFingerprintParts')->willReturn([]); $this->client->method('getContingentCalendar')->willReturn($this->response([ new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok), ])); // The near-term job syncs only to 2026-07-03 but must not discard the 24-month reach. $this->sync(); self::assertSame('2028-08-20', $state->getHorizonTo()?->format('Y-m-d')); } public function testHorizonGrowsWhenAFurtherWindowIsSynced(): void { $state = new ContingentSyncState($this->accommodation()); $state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2026-01-01')); $this->syncStateRepository->method('findOneByAccommodation')->willReturn($state); $this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]); $this->dayRepository->method('findStatusFingerprintParts')->willReturn([]); $this->client->method('getContingentCalendar')->willReturn($this->response([ new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok), ])); $this->sync(); self::assertSame('2026-07-03', $state->getHorizonTo()?->format('Y-m-d')); } private function sync(): \App\Model\ContingentSyncResult { $manager = new ContingentSnapshotManager( $this->client, $this->dayRepository, $this->syncStateRepository, $this->entityManager, $this->createMock(LoggerInterface::class), ); return $manager->sync( $this->accommodation(), new \DateTimeImmutable('2026-07-01'), new \DateTimeImmutable('2026-07-03'), ); } private function accommodation(): Accommodation { return (new Accommodation())->setCalendarCode('HOTEL1'); } private function day(string $date, ContingentStatus $status): ContingentDay { return (new ContingentDay()) ->setDate(new \DateTimeImmutable($date)) ->setStatus($status); } /** * @param ContingentCalendarEntry[] $entries */ private function response(array $entries): ContingentCalendarResponse { return new ContingentCalendarResponse( new ContingentCalendarMeta('2026-07-01', '2026-07-03', 'days', 'HOTEL1', 1, count($entries)), $entries, ); } }