createController(); $price = $this->createPrice(); $result = $this->enrichEntry($controller, '2026-07-06', 'available', [$price], ['2026-07-06' => true]); self::assertSame('available', $result['status']); self::assertSame(4, $result['includedPax']); self::assertSame(3, $result['minNights']); self::assertSame(123.45, $result['pricePerNight']); self::assertSame('EUR', $result['currency']); } public function testEnrichEntryBlocksDayWithoutPrice(): void { $controller = $this->createController(); $price = $this->createPrice(); $result = $this->enrichEntry($controller, '2026-08-01', 'OK', [$price], ['2026-07-06' => true]); self::assertSame('BLOCKED', $result['status']); self::assertNull($result['pricePerNight']); self::assertNull($result['includedPax']); 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')); } public function testCalendarWithoutARangeCoversTheWholePricedSpan(): void { CarbonImmutable::setTestNow('2026-06-01 09:00:00'); $data = $this->callFullSpanCalendar( ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-08-31')], [$this->createPrice(), $this->createPrice('2026-08-10', '2026-08-31')], ); // Every day between the bounds, gaps included: consumers key off status, so a day that is // simply absent has no status at all and cannot be told apart from one we never mentioned. self::assertSame('2026-07-01', $data[0]['date']); self::assertSame('2026-08-31', $data[array_key_last($data)]['date']); self::assertCount(62, $data); } public function testCalendarWithoutARangeIsContiguous(): void { CarbonImmutable::setTestNow('2026-06-01 09:00:00'); $dates = array_column($this->callFullSpanCalendar( ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-08-31')], [$this->createPrice(), $this->createPrice('2026-08-10', '2026-08-31')], ), 'date'); $expected = []; for ($day = new \DateTimeImmutable('2026-07-01'); $day <= new \DateTimeImmutable('2026-08-31'); $day = $day->modify('+1 day')) { $expected[] = $day->format('Y-m-d'); } self::assertSame($expected, $dates); } public function testCalendarWithoutARangeBlocksDaysNoPriceCovers(): void { CarbonImmutable::setTestNow('2026-06-01 09:00:00'); $data = $this->callFullSpanCalendar( ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-08-31')], [$this->createPrice(), $this->createPrice('2026-08-10', '2026-08-31')], ['2026-07-31' => ContingentStatus::Ok, '2026-08-01' => ContingentStatus::Ok], ); $byDate = array_column($data, null, 'date'); // The closure between the two periods keeps a status, and it is BLOCKED even though the // snapshot reports the day as free: no price means not sold. self::assertSame('OK', $byDate['2026-07-31']['status']); self::assertSame('BLOCKED', $byDate['2026-08-01']['status']); self::assertNull($byDate['2026-08-01']['pricePerNight']); self::assertSame('BLOCKED', $byDate['2026-08-09']['status']); } public function testCalendarWithoutARangeNeverStartsBeforeToday(): void { CarbonImmutable::setTestNow('2026-07-15 09:00:00'); $data = $this->callFullSpanCalendar( ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-07-31')], [$this->createPrice()], ); self::assertSame('2026-07-15', $data[0]['date']); self::assertCount(17, $data); } public function testCalendarWithoutARangeIsEmptyWhenTheHotelHasNoPrices(): void { self::assertSame([], $this->callFullSpanCalendar(null, [])); } public function testCalendarWithoutARangeIsEmptyWhenEveryPricedPeriodHasPassed(): void { CarbonImmutable::setTestNow('2026-09-01 09:00:00'); $data = $this->callFullSpanCalendar( ['from' => new \DateTimeImmutable('2026-07-01'), 'to' => new \DateTimeImmutable('2026-07-31')], [$this->createPrice()], ); self::assertSame([], $data); } public function testExplicitRangeNeverConsultsThePricedBounds(): void { $priceRepository = $this->createMock(AccommodationPriceRepository::class); $priceRepository->expects(self::never())->method('findPricedDateBoundsByHotelCode'); $this->callCalendar([], null, null, $priceRepository); } /** * @param array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null $bounds * @param AccommodationPrice[] $prices * @param array $statuses * * @return array> */ private function callFullSpanCalendar(?array $bounds, array $prices, array $statuses = []): array { $priceRepository = $this->createStub(AccommodationPriceRepository::class); $priceRepository->method('findPricedDateBoundsByHotelCode')->willReturn($bounds); $priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices); $response = $this->callCalendar( $statuses, new ContingentCalendarQuery('HOTEL1'), $prices, $priceRepository, ); return json_decode((string) $response->getContent(), true); } /** * @param array|null $statuses * @param AccommodationPrice[]|null $prices */ private function callCalendar( ?array $statuses, ?ContingentCalendarQuery $query = null, ?array $prices = null, ?AccommodationPriceRepository $priceRepository = null, ): Response { $accommodationRepository = $this->createStub(AccommodationRepository::class); $accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR')); $snapshotReader = $this->createStub(ContingentSnapshotReader::class); $snapshotReader->method('statusesFor')->willReturn($statuses); $priceRepository ??= $this->createStub(AccommodationPriceRepository::class); if (null !== $prices) { $priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices); } $controller = new ContingentController( $accommodationRepository, $priceRepository, $snapshotReader, new PriceTimelineBuilder(), new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)), $this->createStub(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($query ?? new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03')); } private function createController(): ContingentController { return new ContingentController( $this->createStub(AccommodationRepository::class), $this->createStub(AccommodationPriceRepository::class), $this->createStub(ContingentSnapshotReader::class), new PriceTimelineBuilder(), new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)), $this->createStub(LoggerInterface::class), ); } private function createPrice(string $dateFrom = '2026-07-01', string $dateTo = '2026-07-31'): AccommodationPrice { $price = new AccommodationPrice(); $price->setDateFrom(new \DateTimeImmutable($dateFrom)); $price->setDateTo(new \DateTimeImmutable($dateTo)); $price->setIncludedPax(4); $price->setPricePerNight(12345); $price->setPriceAdditionalPerson(1500); $price->setMinNights(3); return $price; } /** * @param AccommodationPrice[] $prices * @param array $covered * * @return array */ private function enrichEntry( ContingentController $controller, string $date, string $status, array $prices, array $covered, ): array { $method = new \ReflectionMethod($controller, 'enrichEntry'); return $method->invoke($controller, $date, $status, $prices, $covered, 'EUR'); } }