From 351fbab498d50cfa332b8eacf0554d286e3638ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 20 Aug 2026 11:46:03 +0200 Subject: [PATCH] feat: improved contingents api performance with db backed snapshots --- config/packages/zenstruck_schedule.yaml | 12 + migrations/Version20260820080235.php | 32 +++ src/BpnConnect/AbstractApiClient.php | 12 + src/Command/BpnSyncContingentsCommand.php | 186 ++++++++++++++ src/Controller/Api/ContingentController.php | 39 ++- .../Groups/Booking/Step1Controller.php | 52 ++-- src/Entity/Groups/ContingentDay.php | 77 ++++++ src/Entity/Groups/ContingentSyncState.php | 174 +++++++++++++ src/Model/ContingentSyncResult.php | 31 +++ .../Groups/AccommodationRepository.php | 16 ++ .../Groups/ContingentDayRepository.php | 126 ++++++++++ .../Groups/ContingentSyncStateRepository.php | 48 ++++ src/Service/ContingentSnapshotManager.php | 162 +++++++++++++ src/Service/ContingentSnapshotReader.php | 92 +++++++ .../Command/BpnSyncContingentsCommandTest.php | 103 ++++++++ .../Api/ContingentControllerTest.php | 74 +++++- .../Groups/Booking/Step1CalendarDataTest.php | 52 ++-- .../Groups/Booking/Step1ControllerTest.php | 12 +- .../Service/ContingentSnapshotManagerTest.php | 228 ++++++++++++++++++ .../Service/ContingentSnapshotReaderTest.php | 102 ++++++++ 20 files changed, 1521 insertions(+), 109 deletions(-) create mode 100644 migrations/Version20260820080235.php create mode 100644 src/Command/BpnSyncContingentsCommand.php create mode 100644 src/Entity/Groups/ContingentDay.php create mode 100644 src/Entity/Groups/ContingentSyncState.php create mode 100644 src/Model/ContingentSyncResult.php create mode 100644 src/Repository/Groups/ContingentDayRepository.php create mode 100644 src/Repository/Groups/ContingentSyncStateRepository.php create mode 100644 src/Service/ContingentSnapshotManager.php create mode 100644 src/Service/ContingentSnapshotReader.php create mode 100644 tests/Command/BpnSyncContingentsCommandTest.php create mode 100644 tests/Service/ContingentSnapshotManagerTest.php create mode 100644 tests/Service/ContingentSnapshotReaderTest.php diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml index a02d482..f0a8e26 100644 --- a/config/packages/zenstruck_schedule.yaml +++ b/config/packages/zenstruck_schedule.yaml @@ -58,3 +58,15 @@ when@prod: - task: app:bpn:xml-sync frequency: "0 20-23,0-7 * * *" description: "Syncs BusPro XML data hourly outside peak hours" + + - task: app:bpn:sync-contingents --horizon-months=3 + frequency: "*/15 8-19 * * *" + description: "Syncs near-term contingent snapshots every 15 min. during peak hours" + + - task: app:bpn:sync-contingents --horizon-months=3 + frequency: "5 20-23,0-7 * * *" + description: "Syncs near-term contingent snapshots hourly outside peak hours" + + - task: app:bpn:sync-contingents --horizon-months=24 + frequency: "40 2,13 * * *" + description: "Syncs the full contingent horizon twice daily" diff --git a/migrations/Version20260820080235.php b/migrations/Version20260820080235.php new file mode 100644 index 0000000..b65b2f4 --- /dev/null +++ b/migrations/Version20260820080235.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE contingent_day (id INT AUTO_INCREMENT NOT NULL, accommodation_id INT NOT NULL, date DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', status VARCHAR(20) NOT NULL, INDEX IDX_8ACC3A068F3692CD (accommodation_id), UNIQUE INDEX uniq_contingent_day_accommodation_date (accommodation_id, date), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE contingent_sync_state (id INT AUTO_INCREMENT NOT NULL, accommodation_id INT NOT NULL, content_hash VARCHAR(64) NOT NULL, changed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', synced_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', horizon_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', last_error LONGTEXT DEFAULT NULL, failure_count INT NOT NULL, UNIQUE INDEX UNIQ_737F167C8F3692CD (accommodation_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE contingent_day ADD CONSTRAINT FK_8ACC3A068F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE contingent_sync_state ADD CONSTRAINT FK_737F167C8F3692CD FOREIGN KEY (accommodation_id) REFERENCES accommodation (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE contingent_day DROP FOREIGN KEY FK_8ACC3A068F3692CD'); + $this->addSql('ALTER TABLE contingent_sync_state DROP FOREIGN KEY FK_737F167C8F3692CD'); + $this->addSql('DROP TABLE contingent_day'); + $this->addSql('DROP TABLE contingent_sync_state'); + } +} diff --git a/src/BpnConnect/AbstractApiClient.php b/src/BpnConnect/AbstractApiClient.php index 22adbdf..ae97f3f 100644 --- a/src/BpnConnect/AbstractApiClient.php +++ b/src/BpnConnect/AbstractApiClient.php @@ -11,6 +11,12 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; abstract class AbstractApiClient { + /** Maximum time to wait between two bytes of the response. */ + private const int IDLE_TIMEOUT_SECONDS = 10; + + /** Hard ceiling on a single request, including a slow-but-alive upstream. */ + private const int MAX_DURATION_SECONDS = 30; + public function __construct( protected readonly HttpClientInterface $httpClient, protected readonly LoggerInterface $logger, @@ -21,6 +27,7 @@ abstract class AbstractApiClient /** * @param array $query + * * @return array */ protected function request(string $path, array $query = []): array @@ -33,6 +40,11 @@ abstract class AbstractApiClient 'X-API-KEY' => $this->apiKey, ], 'query' => $query, + // Without these a stalled upstream hangs the caller indefinitely. That matters + // most for the scheduled contingent sync, which holds a lock: one hung run would + // block every subsequent one and silently let the snapshot rot. + 'timeout' => self::IDLE_TIMEOUT_SECONDS, + 'max_duration' => self::MAX_DURATION_SECONDS, ]); return $response->toArray(); diff --git a/src/Command/BpnSyncContingentsCommand.php b/src/Command/BpnSyncContingentsCommand.php new file mode 100644 index 0000000..24a5b76 --- /dev/null +++ b/src/Command/BpnSyncContingentsCommand.php @@ -0,0 +1,186 @@ +addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync', '24') + ->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single calendarCode') + ->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently') + ; + } + + /** + * Executes the sync flow and the retention cleanup. + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (false === $this->lock('bpn_sync_contingents')) { + $this->logger->info('Contingent sync skipped: another run is still in progress'); + + return Command::SUCCESS; + } + + $horizonMonths = max(1, (int) $input->getOption('horizon-months')); + $staleAfterMinutes = max(0, (int) $input->getOption('stale-after')); + $force = true === $input->getOption('force'); + $hotelCode = $input->getOption('hotel'); + + $today = CarbonImmutable::now()->setTime(0, 0); + $dateFrom = $today->toDateTimeImmutable(); + $dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable(); + + $accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null); + + if ([] === $accommodations) { + $io->warning('No accommodation with a calendarCode matched.'); + + return Command::SUCCESS; + } + + $processed = 0; + $skipped = 0; + $changed = 0; + $failedCodes = []; + + foreach ($accommodations as $accommodation) { + if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) { + ++$skipped; + + continue; + } + + $result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo); + ++$processed; + + if (!$result->successful) { + $failedCodes[] = (string) $accommodation->getCalendarCode(); + $io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error)); + + continue; + } + + if ($result->changed) { + ++$changed; + } + + $io->writeln(sprintf( + '%s: %s (+%d ~%d -%d)', + (string) $accommodation->getCalendarCode(), + $result->changed ? 'changed' : 'unchanged', + $result->added, + $result->updated, + $result->removed, + ), OutputInterface::VERBOSITY_VERBOSE); + } + + $failed = count($failedCodes); + + $summary = [ + 'processed' => $processed, + 'skipped' => $skipped, + 'changed' => $changed, + 'failed' => $failed, + 'failedHotelCodes' => $failedCodes, + 'dateFrom' => $dateFrom->format('Y-m-d'), + 'dateTo' => $dateTo->format('Y-m-d'), + ]; + + $io->success(sprintf( + 'Contingent sync complete: %d processed, %d skipped, %d changed, %d failed', + $processed, + $skipped, + $changed, + $failed, + )); + + $this->logger->info('Contingent snapshot sync finished', $summary); + + $deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable()); + $io->note(sprintf('Deleted %d outdated snapshot days.', $deleted)); + + // Only a *total* failure is worth failing the task over: that means upstream is down or + // the API key is rejected, and the scheduler's failure mail is genuinely actionable. + // A single hotel breaking must stay quiet — this task runs every 15 minutes, so failing + // on it would mail dozens of times a day until someone silenced the task. That hotel is + // not lost track of: its own calendar serves a 502, and contingent_sync_state records + // failure_count and last_error. + return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS; + } + + /** + * @return Accommodation[] + */ + private function resolveAccommodations(?string $hotelCode): array + { + if (null === $hotelCode) { + return $this->accommodationRepository->findAllWithCalendarCode(); + } + + $accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode); + + return null !== $accommodation ? [$accommodation] : []; + } + + private function isFresh(Accommodation $accommodation, int $staleAfterMinutes): bool + { + if (0 === $staleAfterMinutes) { + return false; + } + + $syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt(); + + if (null === $syncedAt) { + return false; + } + + return $syncedAt > CarbonImmutable::now()->subMinutes($staleAfterMinutes); + } +} diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index 547693d..72754f3 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace App\Controller\Api; -use App\BpnConnect\ContingentsClient; -use App\BpnConnect\Exception\BpnConnectException; use App\BpnConnect\Model\ContingentStatus; use App\Entity\Groups\AccommodationPrice; use App\Enum\Groups\PriceType; @@ -14,9 +12,9 @@ use App\Model\ContingentPricesQuery; use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationRepository; use App\Service\AccommodationPriceCoverage; +use App\Service\ContingentSnapshotReader; use App\Service\PriceTimelineBuilder; use Carbon\CarbonImmutable; -use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -24,18 +22,15 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapQueryString; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; #[Route('/api')] #[IsGranted('ROLE_OAUTH2_API')] class ContingentController extends AbstractController { public function __construct( - private readonly ContingentsClient $contingentsClient, private readonly AccommodationRepository $accommodationRepository, private readonly AccommodationPriceRepository $priceRepository, - private readonly CacheInterface $cache, + private readonly ContingentSnapshotReader $snapshotReader, private readonly PriceTimelineBuilder $priceTimelineBuilder, private readonly AccommodationPriceCoverage $priceCoverage, private readonly LoggerInterface $logger, @@ -88,18 +83,12 @@ class ContingentController extends AbstractController return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); } - try { - $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $query->hotelCode, $query->dateFrom, $query->dateTo); - $calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($query) { - $item->expiresAfter(3600); - - return $this->contingentsClient->getContingentCalendar($query->hotelCode, $query->dateFrom, $query->dateTo); - }); - } catch (BpnConnectException|InvalidArgumentException $e) { - $this->logger->error('Failed to fetch contingent data', [ - 'error' => $e->getMessage(), - ]); + // An empty or stale snapshot must not be served as though every day were blocked: that + // is a plausible-looking 200 nobody can distinguish from real data. Fail the way this + // endpoint always failed instead, so existing consumers need no change. + $statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo); + if (null === $statuses) { return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); } @@ -109,10 +98,16 @@ class ContingentController extends AbstractController $covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo); - $data = array_map( - fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $covered, $currency), - $calendar->data, - ); + $data = []; + + for ($day = $dateFrom; $day <= $dateTo; $day = $day->modify('+1 day')) { + $date = $day->format('Y-m-d'); + + // Days the snapshot does not know about are not sold, same rule as days without a price. + $status = $statuses[$date] ?? ContingentStatus::Blocked; + + $data[] = $this->enrichEntry($date, $status->value, $prices, $covered, $currency); + } return $this->json($data); } diff --git a/src/Controller/Groups/Booking/Step1Controller.php b/src/Controller/Groups/Booking/Step1Controller.php index c5bd371..171185d 100644 --- a/src/Controller/Groups/Booking/Step1Controller.php +++ b/src/Controller/Groups/Booking/Step1Controller.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace App\Controller\Groups\Booking; -use App\BpnConnect\ContingentsClient; -use App\BpnConnect\Exception\BpnConnectException; use App\BpnConnect\Model\ContingentStatus; +use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationPrice; use App\Exception\AccommodationSessionNotFoundException; use App\Htmx\HxTrait; @@ -16,13 +15,12 @@ use App\Service\AccommodationBookingService; use App\Service\AccommodationPriceCoverage; use App\Service\AccommodationSessionManager; use App\Service\CalendarGridBuilder; +use App\Service\ContingentSnapshotReader; use App\Service\GroupsPriceCalculator; use App\Service\PriceTimelineBuilder; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; class Step1Controller extends AbstractController { @@ -33,10 +31,9 @@ class Step1Controller extends AbstractController public function __construct( private readonly AccommodationBookingService $bookingService, private readonly AccommodationSessionManager $sessionManager, - private readonly ContingentsClient $contingentsClient, private readonly AccommodationPriceRepository $priceRepository, private readonly PriceTimelineBuilder $priceTimelineBuilder, - private readonly CacheInterface $cache, + private readonly ContingentSnapshotReader $snapshotReader, private readonly CalendarGridBuilder $calendarGridBuilder, private readonly GroupsPriceCalculator $priceCalculator, private readonly AccommodationPriceCoverage $priceCoverage, @@ -90,7 +87,7 @@ class Step1Controller extends AbstractController } $priceBreakdown = null; - if ($dto->dateFrom !== null && $dto->dateTo !== null) { + if (null !== $dto->dateFrom && null !== $dto->dateTo) { $prices = $this->bookingService->loadPrices($dto, $accommodation); $priceBreakdown = $this->priceCalculator->calculate( $dto->paxCount, @@ -149,7 +146,7 @@ class Step1Controller extends AbstractController } $priceBreakdown = null; - if ($dto->dateFrom !== null && $dto->dateTo !== null) { + if (null !== $dto->dateFrom && null !== $dto->dateTo) { $prices = $this->bookingService->loadPrices($dto, $accommodation); $priceBreakdown = $this->priceCalculator->calculate( $dto->paxCount, @@ -199,7 +196,7 @@ class Step1Controller extends AbstractController $offset = max(0, min($request->query->getInt('offset'), $maxOffset)); } else { $offset = 0; - if ($dto->dateFrom !== null) { + if (null !== $dto->dateFrom) { $monthsDiff = ((int) $dto->dateFrom->format('Y') - (int) $calendarStart->format('Y')) * 12 + ((int) $dto->dateFrom->format('n') - (int) $calendarStart->format('n')); $offset = max(0, min($monthsDiff, $maxOffset)); @@ -210,11 +207,10 @@ class Step1Controller extends AbstractController $months = $this->calendarGridBuilder->buildMonths($displayFrom, 2); $enrichedByDate = $this->buildEnrichedDayData( + $accommodation, $hotelCode, $calendarStart, $calendarEnd, - $calendarStart->format('Y-m-d'), - $calendarEnd->format('Y-m-d'), ); return $this->render('groups/booking/_price_calendar_grid.html.twig', [ @@ -227,43 +223,25 @@ class Step1Controller extends AbstractController } /** - * Fetches contingent + price data and returns a map of date → ['status', 'minNights'] + * Reads contingent + price data and returns a map of date → ['status', 'minNights'] * covering every day of the requested range. * * A day is only available when the contingent allows it *and* an AccommodationPrice - * covers it. On API failure the contingent imposes no restriction and price coverage - * alone decides. + * covers it. Availability comes from the local snapshot; when there is no usable snapshot + * the contingent imposes no restriction and price coverage alone decides, which is how this + * calendar has always behaved when the contingent data was unavailable. * * @return array */ private function buildEnrichedDayData( + ?Accommodation $accommodation, string $hotelCode, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, - string $dateFromStr, - string $dateToStr, ): array { - $calendar = null; - try { - $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr); - $calendar = $this->cache->get( - $cacheKey, - function (ItemInterface $item) use ($hotelCode, $dateFromStr, $dateToStr): mixed { - $item->expiresAfter(3600); - - return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFromStr, $dateToStr); - }, - ); - } catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) { - // Fall through with $calendar === null — price coverage still applies - } - - $contingentStatus = []; - if (null !== $calendar) { - foreach ($calendar->data as $entry) { - $contingentStatus[(new \DateTimeImmutable($entry->date))->format('Y-m-d')] = $entry->status; - } - } + $contingentStatus = null !== $accommodation + ? $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo) ?? [] + : []; $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); $covered = $this->priceCoverage->coveredDatesFor($prices, $dateFrom, $dateTo); diff --git a/src/Entity/Groups/ContingentDay.php b/src/Entity/Groups/ContingentDay.php new file mode 100644 index 0000000..a7726d4 --- /dev/null +++ b/src/Entity/Groups/ContingentDay.php @@ -0,0 +1,77 @@ +id; + } + + public function getAccommodation(): ?Accommodation + { + return $this->accommodation; + } + + public function setAccommodation(?Accommodation $accommodation): self + { + $this->accommodation = $accommodation; + + return $this; + } + + public function getDate(): ?\DateTimeImmutable + { + return $this->date; + } + + public function setDate(\DateTimeImmutable $date): self + { + $this->date = $date; + + return $this; + } + + public function getStatus(): ContingentStatus + { + return $this->status; + } + + public function setStatus(ContingentStatus $status): self + { + $this->status = $status; + + return $this; + } +} diff --git a/src/Entity/Groups/ContingentSyncState.php b/src/Entity/Groups/ContingentSyncState.php new file mode 100644 index 0000000..8908806 --- /dev/null +++ b/src/Entity/Groups/ContingentSyncState.php @@ -0,0 +1,174 @@ +accommodation = $accommodation; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAccommodation(): ?Accommodation + { + return $this->accommodation; + } + + public function setAccommodation(?Accommodation $accommodation): self + { + $this->accommodation = $accommodation; + + return $this; + } + + public function getContentHash(): string + { + return $this->contentHash; + } + + public function setContentHash(string $contentHash): self + { + $this->contentHash = $contentHash; + + return $this; + } + + public function getChangedAt(): ?\DateTimeImmutable + { + return $this->changedAt; + } + + public function setChangedAt(?\DateTimeImmutable $changedAt): self + { + $this->changedAt = $changedAt; + + return $this; + } + + public function getSyncedAt(): ?\DateTimeImmutable + { + return $this->syncedAt; + } + + public function setSyncedAt(?\DateTimeImmutable $syncedAt): self + { + $this->syncedAt = $syncedAt; + + return $this; + } + + public function getHorizonTo(): ?\DateTimeImmutable + { + return $this->horizonTo; + } + + public function setHorizonTo(?\DateTimeImmutable $horizonTo): self + { + $this->horizonTo = $horizonTo; + + return $this; + } + + public function getLastError(): ?string + { + return $this->lastError; + } + + public function setLastError(?string $lastError): self + { + $this->lastError = $lastError; + + return $this; + } + + public function getFailureCount(): int + { + return $this->failureCount; + } + + public function setFailureCount(int $failureCount): self + { + $this->failureCount = $failureCount; + + return $this; + } + + public function recordFailure(string $error): self + { + $this->lastError = $error; + ++$this->failureCount; + + return $this; + } + + /** + * Records a successful sync of a window ending at $horizonTo. + * + * The horizon only ever grows: the near-term job runs far more often than the full-horizon + * one, and must not discard the reach the latter established. + */ + public function recordSuccess(\DateTimeImmutable $syncedAt, \DateTimeImmutable $horizonTo): self + { + $this->syncedAt = $syncedAt; + $this->horizonTo = null === $this->horizonTo || $horizonTo > $this->horizonTo ? $horizonTo : $this->horizonTo; + $this->lastError = null; + $this->failureCount = 0; + + return $this; + } +} diff --git a/src/Model/ContingentSyncResult.php b/src/Model/ContingentSyncResult.php new file mode 100644 index 0000000..b2ec182 --- /dev/null +++ b/src/Model/ContingentSyncResult.php @@ -0,0 +1,31 @@ +getOneOrNullResult() ; } + + /** + * Every accommodation that is addressable by a calendar hotel code, ordered for stable output. + * + * @return Accommodation[] + */ + public function findAllWithCalendarCode(): array + { + return $this->createQueryBuilder('a') + ->andWhere('a.calendarCode IS NOT NULL') + ->andWhere("a.calendarCode != ''") + ->orderBy('a.calendarCode', 'ASC') + ->getQuery() + ->getResult() + ; + } } diff --git a/src/Repository/Groups/ContingentDayRepository.php b/src/Repository/Groups/ContingentDayRepository.php new file mode 100644 index 0000000..20a185f --- /dev/null +++ b/src/Repository/Groups/ContingentDayRepository.php @@ -0,0 +1,126 @@ + + */ +class ContingentDayRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ContingentDay::class); + } + + /** + * Returns the snapshot days within [dateFrom, dateTo] for the accommodation + * identified by the given hotel code, keyed by Y-m-d. + * + * @return array + */ + public function findByHotelCodeAndDateRange( + string $hotelCode, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + ): array { + /** @var ContingentDay[] $days */ + $days = $this->createQueryBuilder('cd') + ->join('cd.accommodation', 'a') + ->where('a.calendarCode = :hotelCode') + ->andWhere('cd.date >= :dateFrom') + ->andWhere('cd.date <= :dateTo') + ->setParameter('hotelCode', $hotelCode) + ->setParameter('dateFrom', $dateFrom) + ->setParameter('dateTo', $dateTo) + ->orderBy('cd.date', 'ASC') + ->getQuery() + ->getResult(); + + return $this->indexByDate($days); + } + + /** + * Same as above but for a known accommodation; used by the sync to diff against. + * + * @return array + */ + public function findByAccommodationAndDateRange( + Accommodation $accommodation, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + ): array { + /** @var ContingentDay[] $days */ + $days = $this->createQueryBuilder('cd') + ->where('cd.accommodation = :accommodation') + ->andWhere('cd.date >= :dateFrom') + ->andWhere('cd.date <= :dateTo') + ->setParameter('accommodation', $accommodation) + ->setParameter('dateFrom', $dateFrom) + ->setParameter('dateTo', $dateTo) + ->orderBy('cd.date', 'ASC') + ->getQuery() + ->getResult(); + + return $this->indexByDate($days); + } + + /** + * Returns the accommodation's complete stored snapshot as an ordered "Y-m-d:STATUS" list. + * + * Scalar hydration keeps the fingerprint cheap: the entities themselves are of no interest here. + * + * @return list + */ + public function findStatusFingerprintParts(Accommodation $accommodation): array + { + /** @var array $rows */ + $rows = $this->createQueryBuilder('cd') + ->select('cd.date', 'cd.status') + ->where('cd.accommodation = :accommodation') + ->setParameter('accommodation', $accommodation) + ->orderBy('cd.date', 'ASC') + ->getQuery() + ->getArrayResult(); + + return array_map( + static fn (array $row) => $row['date']->format('Y-m-d').':'.$row['status']->value, + $rows, + ); + } + + /** + * Retention: drops snapshot days that are in the past and can no longer be requested. + */ + public function deleteBefore(\DateTimeImmutable $date): int + { + return (int) $this->createQueryBuilder('cd') + ->delete() + ->where('cd.date < :date') + ->setParameter('date', $date) + ->getQuery() + ->execute(); + } + + /** + * @param ContingentDay[] $days + * + * @return array + */ + private function indexByDate(array $days): array + { + $indexed = []; + + foreach ($days as $day) { + $indexed[$day->getDate()->format('Y-m-d')] = $day; + } + + return $indexed; + } +} diff --git a/src/Repository/Groups/ContingentSyncStateRepository.php b/src/Repository/Groups/ContingentSyncStateRepository.php new file mode 100644 index 0000000..6a77104 --- /dev/null +++ b/src/Repository/Groups/ContingentSyncStateRepository.php @@ -0,0 +1,48 @@ + + */ +class ContingentSyncStateRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ContingentSyncState::class); + } + + public function findOneByAccommodation(Accommodation $accommodation): ?ContingentSyncState + { + return $this->findOneBy(['accommodation' => $accommodation]); + } + + /** + * Returns every sync state keyed by accommodation id, for the revisions endpoint. + * + * @return array + */ + public function findAllIndexedByAccommodationId(): array + { + $indexed = []; + + foreach ($this->findAll() as $state) { + $accommodation = $state->getAccommodation(); + + if (null === $accommodation || null === $accommodation->getId()) { + continue; + } + + $indexed[$accommodation->getId()] = $state; + } + + return $indexed; + } +} diff --git a/src/Service/ContingentSnapshotManager.php b/src/Service/ContingentSnapshotManager.php new file mode 100644 index 0000000..7036826 --- /dev/null +++ b/src/Service/ContingentSnapshotManager.php @@ -0,0 +1,162 @@ +getCalendarCode(); + + if (null === $hotelCode || '' === $hotelCode) { + return ContingentSyncResult::failed('Accommodation has no calendarCode.'); + } + + $state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation); + + try { + $calendar = $this->contingentsClient->getContingentCalendar( + $hotelCode, + $dateFrom->format('Y-m-d'), + $dateTo->format('Y-m-d'), + ); + } catch (BpnConnectException $e) { + return $this->recordFailure($state, $hotelCode, $e->getMessage()); + } + + $existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $dateFrom, $dateTo); + + // An empty upstream response would otherwise wipe the window and render the whole + // period as BLOCKED. Treat it as an upstream problem and keep the last good snapshot. + if ([] === $calendar->data && [] !== $existing) { + return $this->recordFailure($state, $hotelCode, 'Upstream returned no contingent days for a range that is already populated.'); + } + + $added = 0; + $updated = 0; + $seen = []; + + foreach ($calendar->data as $entry) { + try { + $date = (new \DateTimeImmutable($entry->date))->setTime(0, 0); + } catch (\Exception $e) { + $this->logger->warning('Skipping contingent day with an unparsable date', [ + 'hotelCode' => $hotelCode, + 'date' => $entry->date, + 'error' => $e->getMessage(), + ]); + + continue; + } + + if ($date < $dateFrom || $date > $dateTo) { + continue; + } + + $key = $date->format('Y-m-d'); + $seen[$key] = true; + $day = $existing[$key] ?? null; + + if (null === $day) { + $day = (new ContingentDay()) + ->setAccommodation($accommodation) + ->setDate($date); + + $this->entityManager->persist($day); + ++$added; + } elseif ($day->getStatus() === $entry->status) { + continue; + } else { + ++$updated; + } + + $day->setStatus($entry->status); + } + + $removed = 0; + + foreach ($existing as $key => $day) { + if (!isset($seen[$key])) { + $this->entityManager->remove($day); + ++$removed; + } + } + + $this->entityManager->flush(); + + $now = CarbonImmutable::now()->toDateTimeImmutable(); + $hash = $this->fingerprint($accommodation); + $changed = $hash !== $state->getContentHash(); + + if ($changed) { + $state->setContentHash($hash)->setChangedAt($now); + } + + $state->recordSuccess($now, $dateTo); + $this->entityManager->persist($state); + $this->entityManager->flush(); + + return ContingentSyncResult::synced($changed, $added, $updated, $removed); + } + + /** + * sha256 over the accommodation's complete stored snapshot, contingent status only. + */ + public function fingerprint(Accommodation $accommodation): string + { + return hash('sha256', implode('|', $this->dayRepository->findStatusFingerprintParts($accommodation))); + } + + private function recordFailure(ContingentSyncState $state, string $hotelCode, string $error): ContingentSyncResult + { + $this->logger->error('Contingent snapshot sync failed', [ + 'hotelCode' => $hotelCode, + 'error' => $error, + ]); + + $state->recordFailure($error); + $this->entityManager->persist($state); + $this->entityManager->flush(); + + return ContingentSyncResult::failed($error); + } +} diff --git a/src/Service/ContingentSnapshotReader.php b/src/Service/ContingentSnapshotReader.php new file mode 100644 index 0000000..1a0dd89 --- /dev/null +++ b/src/Service/ContingentSnapshotReader.php @@ -0,0 +1,92 @@ +|null + */ + public function statusesFor( + Accommodation $accommodation, + \DateTimeImmutable $dateFrom, + \DateTimeImmutable $dateTo, + ): ?array { + $hotelCode = $accommodation->getCalendarCode(); + + if (null === $hotelCode || '' === $hotelCode) { + return null; + } + + if (!$this->isUsable($accommodation, $hotelCode)) { + return null; + } + + $statuses = []; + + foreach ($this->dayRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo) as $date => $day) { + $statuses[$date] = $day->getStatus(); + } + + return $statuses; + } + + private function isUsable(Accommodation $accommodation, string $hotelCode): bool + { + $syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt(); + + if (null === $syncedAt) { + $this->logger->error('No contingent snapshot has been synced yet', ['hotelCode' => $hotelCode]); + + return false; + } + + if ($syncedAt < CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE)) { + $this->logger->error('Contingent snapshot is stale', [ + 'hotelCode' => $hotelCode, + 'syncedAt' => $syncedAt->format(\DateTimeInterface::ATOM), + ]); + + return false; + } + + return true; + } +} diff --git a/tests/Command/BpnSyncContingentsCommandTest.php b/tests/Command/BpnSyncContingentsCommandTest.php new file mode 100644 index 0000000..9f8ed74 --- /dev/null +++ b/tests/Command/BpnSyncContingentsCommandTest.php @@ -0,0 +1,103 @@ +runSync(['A', 'B'], [ + ContingentSyncResult::failed('upstream down'), + ContingentSyncResult::failed('upstream down'), + ]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + } + + public function testASingleFailingHotelDoesNotFailTheTask(): void + { + $tester = $this->runSync(['A', 'B', 'C'], [ + ContingentSyncResult::synced(true, 3, 0, 0), + ContingentSyncResult::failed('unknown hotel code'), + ContingentSyncResult::synced(false, 0, 0, 0), + ]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('B', $tester->getDisplay()); + self::assertStringContainsString('unknown hotel code', $tester->getDisplay()); + } + + public function testASuccessfulRunSucceeds(): void + { + $tester = $this->runSync(['A'], [ContingentSyncResult::synced(true, 3, 1, 0)]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + } + + public function testARunWithNoAccommodationsSucceeds(): void + { + $tester = $this->runSync([], []); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + } + + /** + * @param string[] $hotelCodes + * @param ContingentSyncResult[] $results + */ + private function runSync(array $hotelCodes, array $results): CommandTester + { + $accommodations = array_map( + static fn (string $code) => (new Accommodation())->setCalendarCode($code), + $hotelCodes, + ); + + $accommodationRepository = $this->createMock(AccommodationRepository::class); + $accommodationRepository->method('findAllWithCalendarCode')->willReturn($accommodations); + + $manager = $this->createMock(ContingentSnapshotManager::class); + if ([] !== $results) { + $manager->method('sync')->willReturnOnConsecutiveCalls(...$results); + } + + $command = new BpnSyncContingentsCommand( + $accommodationRepository, + $this->createMock(ContingentSyncStateRepository::class), + $this->createMock(ContingentDayRepository::class), + $manager, + new NullLogger(), + ); + + $application = new Application(); + $application->add($command); + + $tester = new CommandTester($application->find('app:bpn:sync-contingents')); + $tester->execute(['--horizon-months' => '1']); + + // LockableTrait's lock lives for the life of the process, so without releasing it every + // run after the first would exit early and these assertions would pass for the wrong reason. + $release = new \ReflectionMethod($command, 'release'); + $release->invoke($command); + + return $tester; + } +} diff --git a/tests/Controller/Api/ContingentControllerTest.php b/tests/Controller/Api/ContingentControllerTest.php index 81beff5..b8ea31c 100644 --- a/tests/Controller/Api/ContingentControllerTest.php +++ b/tests/Controller/Api/ContingentControllerTest.php @@ -4,16 +4,20 @@ declare(strict_types=1); namespace App\Tests\Controller\Api; -use App\BpnConnect\ContingentsClient; +use App\BpnConnect\Model\ContingentStatus; use App\Controller\Api\ContingentController; +use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationPrice; +use App\Model\ContingentCalendarQuery; use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationRepository; use App\Service\AccommodationPriceCoverage; +use App\Service\ContingentSnapshotReader; use App\Service\PriceTimelineBuilder; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Component\DependencyInjection\Container; +use Symfony\Component\HttpFoundation\Response; class ContingentControllerTest extends TestCase { @@ -44,13 +48,75 @@ class ContingentControllerTest extends TestCase self::assertNull($result['minNights']); } + public function testCalendarFailsLoudlyWhenThereIsNoUsableSnapshot(): void + { + $response = $this->callCalendar(null); + + self::assertSame(Response::HTTP_BAD_GATEWAY, $response->getStatusCode()); + self::assertSame('{"error":"Failed to fetch contingent data."}', $response->getContent()); + } + + public function testCalendarServesTheSnapshotStatuses(): void + { + $response = $this->callCalendar([ + '2026-09-01' => ContingentStatus::Ok, + '2026-09-02' => ContingentStatus::OnRequest, + ]); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + + $data = json_decode((string) $response->getContent(), true); + + // One entry per requested day, and the format is unchanged from the upstream-backed version. + self::assertCount(3, $data); + self::assertSame( + ['date', 'status', 'type', 'pricePerNight', 'defaultPricePerNight', 'priceAdditionalPerson', 'defaultPriceAdditionalPerson', 'currency', 'includedPax', 'minNights'], + array_keys($data[0]), + ); + // No price covers these days, so the "not sold without a price" rule blocks them all. + self::assertSame(['BLOCKED', 'BLOCKED', 'BLOCKED'], array_column($data, 'status')); + } + + public function testCalendarBlocksDaysMissingFromTheSnapshot(): void + { + $data = json_decode((string) $this->callCalendar([])->getContent(), true); + + self::assertSame(['BLOCKED', 'BLOCKED', 'BLOCKED'], array_column($data, 'status')); + } + + /** + * @param array|null $statuses + */ + private function callCalendar(?array $statuses): Response + { + $accommodationRepository = $this->createMock(AccommodationRepository::class); + $accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR')); + + $snapshotReader = $this->createMock(ContingentSnapshotReader::class); + $snapshotReader->method('statusesFor')->willReturn($statuses); + + $controller = new ContingentController( + $accommodationRepository, + $this->createMock(AccommodationPriceRepository::class), + $snapshotReader, + new PriceTimelineBuilder(), + new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)), + $this->createMock(LoggerInterface::class), + ); + + // No 'serializer' service registered, so AbstractController::json() falls back to + // JsonResponse — which is what this endpoint produces in production anyway. + $controller->setContainer(new Container()); + + return $controller->calendar(new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03')); + } + private function createController(): ContingentController { return new ContingentController( - $this->createMock(ContingentsClient::class), $this->createMock(AccommodationRepository::class), $this->createMock(AccommodationPriceRepository::class), - $this->createMock(CacheInterface::class), + $this->createMock(ContingentSnapshotReader::class), new PriceTimelineBuilder(), new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)), $this->createMock(LoggerInterface::class), diff --git a/tests/Controller/Groups/Booking/Step1CalendarDataTest.php b/tests/Controller/Groups/Booking/Step1CalendarDataTest.php index 868a6bf..6f4c1bb 100644 --- a/tests/Controller/Groups/Booking/Step1CalendarDataTest.php +++ b/tests/Controller/Groups/Booking/Step1CalendarDataTest.php @@ -4,22 +4,19 @@ declare(strict_types=1); namespace App\Tests\Controller\Groups\Booking; -use App\BpnConnect\ContingentsClient; -use App\BpnConnect\Model\ContingentCalendarEntry; -use App\BpnConnect\Model\ContingentCalendarMeta; -use App\BpnConnect\Model\ContingentCalendarResponse; use App\BpnConnect\Model\ContingentStatus; use App\Controller\Groups\Booking\Step1Controller; +use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationPrice; use App\Repository\Groups\AccommodationPriceRepository; use App\Service\AccommodationBookingService; use App\Service\AccommodationPriceCoverage; use App\Service\AccommodationSessionManager; use App\Service\CalendarGridBuilder; +use App\Service\ContingentSnapshotReader; use App\Service\GroupsPriceCalculator; use App\Service\PriceTimelineBuilder; use PHPUnit\Framework\TestCase; -use Symfony\Contracts\Cache\CacheInterface; class Step1CalendarDataTest extends TestCase { @@ -32,7 +29,7 @@ class Step1CalendarDataTest extends TestCase $price->setMinNights(2); $enriched = $this->buildEnrichedDayData( - $this->calendarResponse([ + [ '2026-06-01' => ContingentStatus::Ok, '2026-06-02' => ContingentStatus::Ok, '2026-06-03' => ContingentStatus::Ok, @@ -40,7 +37,7 @@ class Step1CalendarDataTest extends TestCase '2026-06-05' => ContingentStatus::Ok, '2026-06-06' => ContingentStatus::Ok, '2026-06-07' => ContingentStatus::Ok, - ]), + ], [$price], '2026-06-01', '2026-06-07', @@ -68,11 +65,11 @@ class Step1CalendarDataTest extends TestCase $price->setMinNights(1); $enriched = $this->buildEnrichedDayData( - $this->calendarResponse([ + [ '2026-06-01' => ContingentStatus::Ok, '2026-06-02' => ContingentStatus::Blocked, '2026-06-03' => ContingentStatus::Ok, - ]), + ], [$price], '2026-06-01', '2026-06-03', @@ -83,7 +80,7 @@ class Step1CalendarDataTest extends TestCase self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']); } - public function testPriceCoverageStillAppliesWhenContingentApiIsUnavailable(): void + public function testPriceCoverageStillAppliesWhenThereIsNoUsableSnapshot(): void { $price = new AccommodationPrice(); $price->setDateFrom(new \DateTimeImmutable('2026-06-01')); @@ -98,38 +95,19 @@ class Step1CalendarDataTest extends TestCase } /** - * @param array $statuses - */ - private function calendarResponse(array $statuses): ContingentCalendarResponse - { - $entries = []; - foreach ($statuses as $date => $status) { - $entries[] = new ContingentCalendarEntry(date: $date, status: $status); - } - - return new ContingentCalendarResponse( - new ContingentCalendarMeta('', '', 'days', 'HOTEL', 1, count($entries)), - $entries, - ); - } - - /** - * @param AccommodationPrice[] $prices + * @param array|null $statuses null = no usable snapshot + * @param AccommodationPrice[] $prices * * @return array */ private function buildEnrichedDayData( - ?ContingentCalendarResponse $calendar, + ?array $statuses, array $prices, string $dateFrom, string $dateTo, ): array { - $cache = $this->createMock(CacheInterface::class); - if (null === $calendar) { - $cache->method('get')->willThrowException(new \App\BpnConnect\Exception\BpnConnectException('down')); - } else { - $cache->method('get')->willReturn($calendar); - } + $snapshotReader = $this->createMock(ContingentSnapshotReader::class); + $snapshotReader->method('statusesFor')->willReturn($statuses); $priceRepository = $this->createMock(AccommodationPriceRepository::class); $priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices); @@ -137,10 +115,9 @@ class Step1CalendarDataTest extends TestCase $controller = new Step1Controller( $this->createMock(AccommodationBookingService::class), $this->createMock(AccommodationSessionManager::class), - $this->createMock(ContingentsClient::class), $priceRepository, new PriceTimelineBuilder(), - $cache, + $snapshotReader, $this->createMock(CalendarGridBuilder::class), $this->createMock(GroupsPriceCalculator::class), new AccommodationPriceCoverage($priceRepository), @@ -150,11 +127,10 @@ class Step1CalendarDataTest extends TestCase return $method->invoke( $controller, + (new Accommodation())->setCalendarCode('HOTEL'), 'HOTEL', new \DateTimeImmutable($dateFrom), new \DateTimeImmutable($dateTo), - $dateFrom, - $dateTo, ); } } diff --git a/tests/Controller/Groups/Booking/Step1ControllerTest.php b/tests/Controller/Groups/Booking/Step1ControllerTest.php index fa898d7..50a8b4c 100644 --- a/tests/Controller/Groups/Booking/Step1ControllerTest.php +++ b/tests/Controller/Groups/Booking/Step1ControllerTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Tests\Controller\Groups\Booking; -use App\BpnConnect\ContingentsClient; use App\Controller\Groups\Booking\Step1Controller; use App\Entity\Groups\Accommodation; use App\Form\Model\AccommodationBookingDto; @@ -13,6 +12,7 @@ use App\Service\AccommodationBookingService; use App\Service\AccommodationPriceCoverage; use App\Service\AccommodationSessionManager; use App\Service\CalendarGridBuilder; +use App\Service\ContingentSnapshotReader; use App\Service\GroupsPriceCalculator; use App\Service\PriceTimelineBuilder; use PHPUnit\Framework\TestCase; @@ -21,7 +21,6 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; -use Symfony\Contracts\Cache\CacheInterface; class Step1ControllerTest extends TestCase { @@ -111,10 +110,9 @@ class Step1ControllerTest extends TestCase return new TestableAccommodationStep1Controller( $bookingService, $sessionManager, - $this->createMock(ContingentsClient::class), $priceRepository, new PriceTimelineBuilder(), - $this->createMock(CacheInterface::class), + $this->createMock(ContingentSnapshotReader::class), new CalendarGridBuilder(), new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]), new AccommodationPriceCoverage($priceRepository), @@ -127,10 +125,9 @@ final class TestableAccommodationStep1Controller extends Step1Controller public function __construct( AccommodationBookingService $bookingService, AccommodationSessionManager $sessionManager, - ContingentsClient $contingentsClient, AccommodationPriceRepository $priceRepository, PriceTimelineBuilder $priceTimelineBuilder, - CacheInterface $cache, + ContingentSnapshotReader $snapshotReader, CalendarGridBuilder $calendarGridBuilder, GroupsPriceCalculator $priceCalculator, AccommodationPriceCoverage $priceCoverage, @@ -138,10 +135,9 @@ final class TestableAccommodationStep1Controller extends Step1Controller parent::__construct( $bookingService, $sessionManager, - $contingentsClient, $priceRepository, $priceTimelineBuilder, - $cache, + $snapshotReader, $calendarGridBuilder, $priceCalculator, $priceCoverage, diff --git a/tests/Service/ContingentSnapshotManagerTest.php b/tests/Service/ContingentSnapshotManagerTest.php new file mode 100644 index 0000000..c99e432 --- /dev/null +++ b/tests/Service/ContingentSnapshotManagerTest.php @@ -0,0 +1,228 @@ +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, + ); + } +} diff --git a/tests/Service/ContingentSnapshotReaderTest.php b/tests/Service/ContingentSnapshotReaderTest.php new file mode 100644 index 0000000..4f02a04 --- /dev/null +++ b/tests/Service/ContingentSnapshotReaderTest.php @@ -0,0 +1,102 @@ +dayRepository = $this->createMock(ContingentDayRepository::class); + $this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class); + } + + public function testReturnsNullWhenNothingHasEverSynced(): void + { + $this->syncStateRepository->method('findOneByAccommodation')->willReturn(null); + $this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange'); + + self::assertNull($this->read()); + } + + public function testReturnsNullWhenTheSnapshotIsStale(): void + { + $this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-7 hours')); + $this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange'); + + self::assertNull($this->read()); + } + + public function testReturnsStatusesWhenRecentlySynced(): void + { + $this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-10 minutes')); + $this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([ + '2026-09-01' => $this->day(ContingentStatus::Ok), + '2026-09-02' => $this->day(ContingentStatus::OnRequest), + ]); + + self::assertSame([ + '2026-09-01' => ContingentStatus::Ok, + '2026-09-02' => ContingentStatus::OnRequest, + ], $this->read()); + } + + public function testStillUsableJustInsideTheStalenessWindow(): void + { + $this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-5 hours')); + $this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]); + + self::assertSame([], $this->read()); + } + + public function testReturnsNullForAnAccommodationWithoutACalendarCode(): void + { + $this->syncStateRepository->expects(self::never())->method('findOneByAccommodation'); + + self::assertNull($this->read(new Accommodation())); + } + + /** + * @return array|null + */ + private function read(?Accommodation $accommodation = null): ?array + { + $reader = new ContingentSnapshotReader( + $this->dayRepository, + $this->syncStateRepository, + $this->createMock(LoggerInterface::class), + ); + + return $reader->statusesFor( + $accommodation ?? (new Accommodation())->setCalendarCode('HOTEL1'), + new \DateTimeImmutable('2026-09-01'), + new \DateTimeImmutable('2026-09-03'), + ); + } + + private function state(string $syncedAgo): ContingentSyncState + { + return (new ContingentSyncState())->setSyncedAt(CarbonImmutable::now()->modify($syncedAgo)->toDateTimeImmutable()); + } + + private function day(ContingentStatus $status): ContingentDay + { + return (new ContingentDay())->setStatus($status); + } +}