diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index 128e29ab..d1b5b461 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -507,6 +507,27 @@ Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations `502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel. +#### `GET /api/contingents/availability?hotelCode=…&dateFrom=…&dateTo=…` — scope `api` + +The raw contingent snapshot per day, without prices. Built for the iCal feed that external booking portals import, which blocks only what the contingent itself reports as unavailable. + +Parameters are the same as for `calendar`: `hotelCode` is required, `dateFrom` and `dateTo` are optional, `Y-m-d`, inclusive, and must be supplied together (at most 366 days). **Omit both to get every snapshot day from today to the end of the snapshot** (the 24-month sync horizon). Prices play no part in that span. + +Each entry, ordered by date: + +```json +{"date": "2027-01-30", "status": "BLOCKED"} +``` + +`status` is `OK`, `BLOCKED` or `ON_REQUEST`. + +Two differences from `calendar`: + +- **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`. +- **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly. + +Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import. + --- ### Operating the snapshot (deployment) diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index 716e8ad5..fda2e05d 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -133,6 +133,47 @@ class ContingentController extends AbstractController return $this->json($data); } + /** + * The snapshot as it is, for the iCal feed: no price rule, and days the snapshot lacks are + * left out rather than blocked, so a portal shows them as free like it did with the feed + * that read bpn-connect directly. + */ + #[Route(path: '/contingents/availability', name: 'api_contingents_availability', methods: ['GET'])] + public function availability( + #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + ContingentCalendarQuery $query, + ): JsonResponse { + $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); + + if (null === $accommodation) { + $this->logger->error('Hotel not found for hotelcode '.$query->hotelCode); + + return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); + } + + // Without a range: from today to wherever the snapshot ends. + $dateFrom = $query->dateFromDate() ?? CarbonImmutable::now()->setTime(0, 0); + $dateTo = $query->dateToDate(); + + // Same reasoning as calendar(): a missing or stale snapshot must not pass for real data. + // Here it matters even more, since an empty feed would show every day as free. + $statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo); + + if (null === $statuses) { + return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); + } + + ksort($statuses); + + $data = []; + + foreach ($statuses as $date => $status) { + $data[] = ['date' => (string) $date, 'status' => $status->value]; + } + + return $this->json($data); + } + /** * @param AccommodationPrice[] $prices * @param array $covered diff --git a/src/Model/ContingentCalendarQuery.php b/src/Model/ContingentCalendarQuery.php index b51004dc..0817af2c 100644 --- a/src/Model/ContingentCalendarQuery.php +++ b/src/Model/ContingentCalendarQuery.php @@ -34,7 +34,7 @@ final readonly class ContingentCalendarQuery $hasFrom = null !== $from && '' !== $from; $hasTo = null !== $to && '' !== $to; - // Neither supplied: the caller wants the full priced span, which the persisted prices define. + // Neither supplied: the caller wants the full span, which each endpoint defines for itself. if (!$hasFrom && !$hasTo) { return; } diff --git a/src/Repository/Groups/ContingentDayRepository.php b/src/Repository/Groups/ContingentDayRepository.php index 9472ddaa..f49e912a 100644 --- a/src/Repository/Groups/ContingentDayRepository.php +++ b/src/Repository/Groups/ContingentDayRepository.php @@ -21,27 +21,31 @@ class ContingentDayRepository extends ServiceEntityRepository /** * Returns the snapshot days within [dateFrom, dateTo] for the accommodation - * identified by the given hotel code, keyed by Y-m-d. + * identified by the given hotel code, keyed by Y-m-d. A null dateTo means + * everything from dateFrom to the end of the snapshot. * * @return array */ public function findByHotelCodeAndDateRange( string $hotelCode, \DateTimeImmutable $dateFrom, - \DateTimeImmutable $dateTo, + ?\DateTimeImmutable $dateTo, ): array { - /** @var ContingentDay[] $days */ - $days = $this->createQueryBuilder('cd') + $qb = $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(); + ->orderBy('cd.date', 'ASC'); + + if (null !== $dateTo) { + $qb->andWhere('cd.date <= :dateTo') + ->setParameter('dateTo', $dateTo); + } + + /** @var ContingentDay[] $days */ + $days = $qb->getQuery()->getResult(); return $this->indexByDate($days); } diff --git a/src/Service/ContingentSnapshotReader.php b/src/Service/ContingentSnapshotReader.php index 016c1775..2e939ab0 100644 --- a/src/Service/ContingentSnapshotReader.php +++ b/src/Service/ContingentSnapshotReader.php @@ -41,14 +41,14 @@ class ContingentSnapshotReader * Per-day statuses keyed by Y-m-d, or null when there is no usable snapshot for this hotel. * * Days inside the range that the snapshot does not cover are simply absent from the map; - * what that means is the caller's decision. + * what that means is the caller's decision. A null dateTo reads to the end of the snapshot. * * @return array|null */ public function statusesFor( Accommodation $accommodation, \DateTimeImmutable $dateFrom, - \DateTimeImmutable $dateTo, + ?\DateTimeImmutable $dateTo, ): ?array { $hotelCode = $accommodation->getCalendarCode(); diff --git a/tests/Controller/Api/ContingentControllerTest.php b/tests/Controller/Api/ContingentControllerTest.php index a5618a68..20d8dd3f 100644 --- a/tests/Controller/Api/ContingentControllerTest.php +++ b/tests/Controller/Api/ContingentControllerTest.php @@ -181,6 +181,117 @@ class ContingentControllerTest extends TestCase $this->callCalendar([], null, null, $priceRepository); } + public function testAvailabilityKeepsTheStatusOfUnpricedDays(): void + { + $data = json_decode((string) $this->callAvailability([ + '2026-09-01' => ContingentStatus::Ok, + '2026-09-02' => ContingentStatus::OnRequest, + '2026-09-03' => ContingentStatus::Blocked, + ])->getContent(), true); + + // The opposite of testCalendarServesTheSnapshotStatuses: no price here, and nothing is blocked for it. + self::assertSame(['OK', 'ON_REQUEST', 'BLOCKED'], array_column($data, 'status')); + } + + public function testAvailabilityLeavesOutDaysMissingFromTheSnapshot(): void + { + $data = json_decode((string) $this->callAvailability([ + '2026-09-03' => ContingentStatus::Blocked, + '2026-09-01' => ContingentStatus::Ok, + ])->getContent(), true); + + self::assertSame([ + ['date' => '2026-09-01', 'status' => 'OK'], + ['date' => '2026-09-03', 'status' => 'BLOCKED'], + ], $data); + } + + public function testAvailabilityFailsLoudlyWhenThereIsNoUsableSnapshot(): void + { + $response = $this->callAvailability(null); + + self::assertSame(Response::HTTP_BAD_GATEWAY, $response->getStatusCode()); + self::assertSame('{"error":"Failed to fetch contingent data."}', $response->getContent()); + } + + public function testAvailabilityRejectsAnUnknownHotel(): void + { + $accommodationRepository = $this->createStub(AccommodationRepository::class); + $accommodationRepository->method('findOneBy')->willReturn(null); + + $response = $this->callAvailability([], accommodationRepository: $accommodationRepository); + + self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + self::assertSame('{"error":"Hotel not found for hotelCode."}', $response->getContent()); + } + + public function testAvailabilityWithoutARangeReadsFromTodayToTheEndOfTheSnapshot(): void + { + CarbonImmutable::setTestNow('2026-10-29 09:00:00'); + + $snapshotReader = $this->createMock(ContingentSnapshotReader::class); + $snapshotReader->expects(self::once()) + ->method('statusesFor') + ->with( + self::anything(), + self::callback(fn (\DateTimeImmutable $from) => '2026-10-29 00:00:00' === $from->format('Y-m-d H:i:s')), + self::isNull(), + ) + ->willReturn([]); + + $response = $this->callAvailability(null, new ContingentCalendarQuery('HOTEL1'), $snapshotReader); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('[]', $response->getContent()); + } + + public function testAvailabilityWithARangePassesItThrough(): void + { + $snapshotReader = $this->createMock(ContingentSnapshotReader::class); + $snapshotReader->expects(self::once()) + ->method('statusesFor') + ->with(self::anything(), new \DateTimeImmutable('2026-09-01'), new \DateTimeImmutable('2026-09-03')) + ->willReturn([]); + + $this->callAvailability(null, snapshotReader: $snapshotReader); + } + + /** + * @param array|null $statuses + */ + private function callAvailability( + ?array $statuses, + ?ContingentCalendarQuery $query = null, + ?ContingentSnapshotReader $snapshotReader = null, + ?AccommodationRepository $accommodationRepository = null, + ): Response { + if (null === $accommodationRepository) { + $accommodationRepository = $this->createStub(AccommodationRepository::class); + $accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR')); + } + + if (null === $snapshotReader) { + $snapshotReader = $this->createStub(ContingentSnapshotReader::class); + $snapshotReader->method('statusesFor')->willReturn($statuses); + } + + // Prices play no part in this endpoint, so any call to their repository is a failure. + $priceRepository = $this->createMock(AccommodationPriceRepository::class); + $priceRepository->expects(self::never())->method(self::anything()); + + $controller = new ContingentController( + $accommodationRepository, + $priceRepository, + $snapshotReader, + new PriceTimelineBuilder(), + new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)), + $this->createStub(LoggerInterface::class), + ); + $controller->setContainer(new Container()); + + return $controller->availability($query ?? new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03')); + } + /** * @param array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null $bounds * @param AccommodationPrice[] $prices