feat: derive calendar date range from price configurations
This commit is contained in:
@@ -180,16 +180,24 @@ Prices are **decimal major units** (converted from integer minor units server-si
|
|||||||
|
|
||||||
#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||||||
|
|
||||||
Per-day availability enriched with prices. Both dates are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); the range may not exceed 366 days and `dateTo` must not precede `dateFrom`.
|
Per-day availability enriched with prices.
|
||||||
|
|
||||||
|
`dateFrom` and `dateTo` are **optional, and must be supplied together** — one without the other is a `400`. Both are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); when supplied, the range may not exceed 366 days and `dateTo` must not precede `dateFrom`.
|
||||||
|
|
||||||
|
**Omit both to get the full priced span.** The range is then derived from the accommodation's persisted prices, `MIN(dateFrom)` to `MAX(dateTo)`, and never starts before today. This is the way to fetch everything a hotel sells in one call, and it is not subject to the 366-day cap — a hotel priced over two seasons returns well over a year of entries.
|
||||||
|
|
||||||
|
Full-span responses are contiguous, exactly like ranged ones: every day between the derived bounds gets an entry, including days no price covers. Those come back `BLOCKED` with `null` price fields — a hotel closed over winter reports the whole closure day by day rather than skipping it, so a consumer keying off `status` always finds one.
|
||||||
|
|
||||||
|
The response is `[]` — a `200`, not a `502` — when the hotel has no prices at all, or when every priced period has already ended.
|
||||||
|
|
||||||
Availability is served from a **local snapshot** refreshed over a 24-month horizon — every 15 minutes during the day, hourly overnight — not fetched from the upstream contingent service per request. The endpoint therefore responds in single-digit milliseconds and stays available during an upstream outage, at the cost of being at most one sync interval stale.
|
Availability is served from a **local snapshot** refreshed over a 24-month horizon — every 15 minutes during the day, hourly overnight — not fetched from the upstream contingent service per request. The endpoint therefore responds in single-digit milliseconds and stays available during an upstream outage, at the cost of being at most one sync interval stale.
|
||||||
|
|
||||||
The response format is unchanged from the previous upstream-backed implementation: same fields, same order, same types, same `status` values. One behavioural note — the endpoint now returns **one entry per requested day**. Previously days the upstream service did not mention were simply absent, so a response may now contain days it would not have contained before. It is a superset, never a different shape.
|
The response format is unchanged from the previous upstream-backed implementation: same fields, same order, same types, same `status` values. One behavioural note — the endpoint returns **one entry per day of the range**, whether that range came from `dateFrom`/`dateTo` or was derived from the prices. Previously days the upstream service did not mention were simply absent, so a response may contain days it would not have contained before. It is a superset, never a different shape.
|
||||||
|
|
||||||
Two kinds of day resolve to `BLOCKED` regardless of the underlying contingent:
|
Two kinds of day resolve to `BLOCKED` regardless of the underlying contingent:
|
||||||
|
|
||||||
- **days in the past** — the snapshot is maintained from today forward only (`prices` likewise refuses past ranges)
|
- **days in the past** — the snapshot is maintained from today forward only (`prices` likewise refuses past ranges)
|
||||||
- **days beyond the synced horizon** — currently 24 months out
|
- **days beyond the synced horizon** — currently 24 months out. Prices are often maintained further ahead than the contingent is synced, so a full-span response can end in a run of `BLOCKED` days that simply have no availability data yet.
|
||||||
|
|
||||||
Each entry:
|
Each entry:
|
||||||
|
|
||||||
@@ -208,7 +216,7 @@ Each entry:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case.
|
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case. This applies to both modes.
|
||||||
|
|
||||||
Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters.
|
Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters.
|
||||||
|
|
||||||
|
|||||||
@@ -72,9 +72,6 @@ class ContingentController extends AbstractController
|
|||||||
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||||
ContingentCalendarQuery $query,
|
ContingentCalendarQuery $query,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$dateFrom = $query->dateFromDate();
|
|
||||||
$dateTo = $query->dateToDate();
|
|
||||||
|
|
||||||
$accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
|
$accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
|
||||||
|
|
||||||
if (null === $accommodation) {
|
if (null === $accommodation) {
|
||||||
@@ -83,6 +80,30 @@ class ContingentController extends AbstractController
|
|||||||
return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
|
return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$dateFrom = $query->dateFromDate();
|
||||||
|
$dateTo = $query->dateToDate();
|
||||||
|
|
||||||
|
// Without an explicit range the persisted prices define one: what we have priced is what we sell.
|
||||||
|
if (null === $dateFrom || null === $dateTo) {
|
||||||
|
$bounds = $this->priceRepository->findPricedDateBoundsByHotelCode($query->hotelCode);
|
||||||
|
|
||||||
|
// Nothing priced means nothing sold, so there is no calendar to serve. This returns before
|
||||||
|
// the snapshot is consulted, and deliberately so: the 502 below exists to avoid passing
|
||||||
|
// unknown availability off as "all blocked", and here there is nothing to be unsure about.
|
||||||
|
if (null === $bounds) {
|
||||||
|
return $this->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past date ranges are of no use to API consumers: never start the range before today.
|
||||||
|
$dateFrom = max($bounds['from'], CarbonImmutable::now()->setTime(0, 0));
|
||||||
|
$dateTo = $bounds['to'];
|
||||||
|
|
||||||
|
// Every priced period has already ended.
|
||||||
|
if ($dateFrom > $dateTo) {
|
||||||
|
return $this->json([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// An empty or stale snapshot must not be served as though every day were blocked: that
|
// 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
|
// 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.
|
// endpoint always failed instead, so existing consumers need no change.
|
||||||
|
|||||||
@@ -17,21 +17,40 @@ final readonly class ContingentCalendarQuery
|
|||||||
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
|
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
|
||||||
public string $hotelCode,
|
public string $hotelCode,
|
||||||
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
#[Assert\Date]
|
#[Assert\Date]
|
||||||
public string $dateFrom,
|
public ?string $dateFrom = null,
|
||||||
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
#[Assert\Date]
|
#[Assert\Date]
|
||||||
public string $dateTo,
|
public ?string $dateTo = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Assert\Callback]
|
#[Assert\Callback]
|
||||||
public function validateRange(ExecutionContextInterface $context): void
|
public function validateRange(ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
$dateFrom = self::parseDate($this->dateFrom);
|
$from = $this->dateFrom;
|
||||||
$dateTo = self::parseDate($this->dateTo);
|
$to = $this->dateTo;
|
||||||
|
|
||||||
|
$hasFrom = null !== $from && '' !== $from;
|
||||||
|
$hasTo = null !== $to && '' !== $to;
|
||||||
|
|
||||||
|
// Neither supplied: the caller wants the full priced span, which the persisted prices define.
|
||||||
|
if (!$hasFrom && !$hasTo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lone dateFrom would have to mean "from there to the end of the prices", a third mode
|
||||||
|
// nobody asked for. Demand the pair instead of inventing a meaning for half of it.
|
||||||
|
if (!$hasFrom || !$hasTo) {
|
||||||
|
$context->buildViolation('dateFrom and dateTo must be supplied together.')
|
||||||
|
->atPath($hasFrom ? 'dateTo' : 'dateFrom')
|
||||||
|
->addViolation();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateFrom = self::parseDate($from);
|
||||||
|
$dateTo = self::parseDate($to);
|
||||||
|
|
||||||
if (null === $dateFrom || null === $dateTo) {
|
if (null === $dateFrom || null === $dateTo) {
|
||||||
return;
|
return;
|
||||||
@@ -52,15 +71,29 @@ final readonly class ContingentCalendarQuery
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dateFromDate(): \DateTimeImmutable
|
/**
|
||||||
|
* Null when the parameter was not supplied, which puts the request into full-span mode.
|
||||||
|
*/
|
||||||
|
public function dateFromDate(): ?\DateTimeImmutable
|
||||||
{
|
{
|
||||||
return self::parseDate($this->dateFrom)
|
return $this->suppliedDate($this->dateFrom);
|
||||||
?? throw new \LogicException('ContingentCalendarQuery must be validated before use.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dateToDate(): \DateTimeImmutable
|
/**
|
||||||
|
* Null when the parameter was not supplied, which puts the request into full-span mode.
|
||||||
|
*/
|
||||||
|
public function dateToDate(): ?\DateTimeImmutable
|
||||||
{
|
{
|
||||||
return self::parseDate($this->dateTo)
|
return $this->suppliedDate($this->dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function suppliedDate(?string $value): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
if (null === $value || '' === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::parseDate($value)
|
||||||
?? throw new \LogicException('ContingentCalendarQuery must be validated before use.');
|
?? throw new \LogicException('ContingentCalendarQuery must be validated before use.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,33 @@ class AccommodationPriceRepository extends ServiceEntityRepository
|
|||||||
->getQuery()
|
->getQuery()
|
||||||
->getResult();
|
->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outer bounds of every persisted price period for the accommodation, or null when it has none.
|
||||||
|
*
|
||||||
|
* @return array{from: \DateTimeImmutable, to: \DateTimeImmutable}|null
|
||||||
|
*/
|
||||||
|
public function findPricedDateBoundsByHotelCode(string $hotelCode): ?array
|
||||||
|
{
|
||||||
|
/** @var array{minFrom: string|null, maxTo: string|null} $bounds */
|
||||||
|
$bounds = $this->createQueryBuilder('ap')
|
||||||
|
->select('MIN(ap.dateFrom) AS minFrom', 'MAX(ap.dateTo) AS maxTo')
|
||||||
|
->join('ap.accommodation', 'a')
|
||||||
|
->where('a.calendarCode = :hotelCode')
|
||||||
|
->setParameter('hotelCode', $hotelCode)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleResult();
|
||||||
|
|
||||||
|
// An aggregate over no rows still returns one row, with both bounds null.
|
||||||
|
if (null === $bounds['minFrom'] || null === $bounds['maxTo']) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both columns are DATE_IMMUTABLE, so the aggregates come back as Y-m-d and land at 00:00 -
|
||||||
|
// the same normalisation the day-by-day comparisons downstream expect.
|
||||||
|
return [
|
||||||
|
'from' => new \DateTimeImmutable($bounds['minFrom']),
|
||||||
|
'to' => new \DateTimeImmutable($bounds['maxTo']),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Repository\Groups\AccommodationRepository;
|
|||||||
use App\Service\AccommodationPriceCoverage;
|
use App\Service\AccommodationPriceCoverage;
|
||||||
use App\Service\ContingentSnapshotReader;
|
use App\Service\ContingentSnapshotReader;
|
||||||
use App\Service\PriceTimelineBuilder;
|
use App\Service\PriceTimelineBuilder;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Container;
|
use Symfony\Component\DependencyInjection\Container;
|
||||||
@@ -21,6 +22,11 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
|
|
||||||
class ContingentControllerTest extends TestCase
|
class ContingentControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
}
|
||||||
|
|
||||||
public function testEnrichEntryIncludesPricingMetadata(): void
|
public function testEnrichEntryIncludesPricingMetadata(): void
|
||||||
{
|
{
|
||||||
$controller = $this->createController();
|
$controller = $this->createController();
|
||||||
@@ -84,20 +90,145 @@ class ContingentControllerTest extends TestCase
|
|||||||
self::assertSame(['BLOCKED', 'BLOCKED', 'BLOCKED'], array_column($data, 'status'));
|
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<string, ContingentStatus> $statuses
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function callFullSpanCalendar(?array $bounds, array $prices, array $statuses = []): array
|
||||||
|
{
|
||||||
|
$priceRepository = $this->createMock(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<string, ContingentStatus>|null $statuses
|
* @param array<string, ContingentStatus>|null $statuses
|
||||||
|
* @param AccommodationPrice[]|null $prices
|
||||||
*/
|
*/
|
||||||
private function callCalendar(?array $statuses): Response
|
private function callCalendar(
|
||||||
{
|
?array $statuses,
|
||||||
|
?ContingentCalendarQuery $query = null,
|
||||||
|
?array $prices = null,
|
||||||
|
?AccommodationPriceRepository $priceRepository = null,
|
||||||
|
): Response {
|
||||||
$accommodationRepository = $this->createMock(AccommodationRepository::class);
|
$accommodationRepository = $this->createMock(AccommodationRepository::class);
|
||||||
$accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR'));
|
$accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR'));
|
||||||
|
|
||||||
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
|
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
|
||||||
$snapshotReader->method('statusesFor')->willReturn($statuses);
|
$snapshotReader->method('statusesFor')->willReturn($statuses);
|
||||||
|
|
||||||
|
$priceRepository ??= $this->createMock(AccommodationPriceRepository::class);
|
||||||
|
|
||||||
|
if (null !== $prices) {
|
||||||
|
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices);
|
||||||
|
}
|
||||||
|
|
||||||
$controller = new ContingentController(
|
$controller = new ContingentController(
|
||||||
$accommodationRepository,
|
$accommodationRepository,
|
||||||
$this->createMock(AccommodationPriceRepository::class),
|
$priceRepository,
|
||||||
$snapshotReader,
|
$snapshotReader,
|
||||||
new PriceTimelineBuilder(),
|
new PriceTimelineBuilder(),
|
||||||
new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)),
|
new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)),
|
||||||
@@ -108,7 +239,7 @@ class ContingentControllerTest extends TestCase
|
|||||||
// JsonResponse — which is what this endpoint produces in production anyway.
|
// JsonResponse — which is what this endpoint produces in production anyway.
|
||||||
$controller->setContainer(new Container());
|
$controller->setContainer(new Container());
|
||||||
|
|
||||||
return $controller->calendar(new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03'));
|
return $controller->calendar($query ?? new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createController(): ContingentController
|
private function createController(): ContingentController
|
||||||
@@ -123,11 +254,11 @@ class ContingentControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createPrice(): AccommodationPrice
|
private function createPrice(string $dateFrom = '2026-07-01', string $dateTo = '2026-07-31'): AccommodationPrice
|
||||||
{
|
{
|
||||||
$price = new AccommodationPrice();
|
$price = new AccommodationPrice();
|
||||||
$price->setDateFrom(new \DateTimeImmutable('2026-07-01'));
|
$price->setDateFrom(new \DateTimeImmutable($dateFrom));
|
||||||
$price->setDateTo(new \DateTimeImmutable('2026-07-31'));
|
$price->setDateTo(new \DateTimeImmutable($dateTo));
|
||||||
$price->setIncludedPax(4);
|
$price->setIncludedPax(4);
|
||||||
$price->setPricePerNight(12345);
|
$price->setPricePerNight(12345);
|
||||||
$price->setPriceAdditionalPerson(1500);
|
$price->setPriceAdditionalPerson(1500);
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ class ContingentQueryTest extends TestCase
|
|||||||
self::assertSame('2026-01-01', $query->dateFromDate()->format('Y-m-d'));
|
self::assertSame('2026-01-01', $query->dateFromDate()->format('Y-m-d'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCalendarQueryAcceptsAnOmittedRange(): void
|
||||||
|
{
|
||||||
|
$query = new ContingentCalendarQuery('HOTEL_1');
|
||||||
|
|
||||||
|
self::assertCount(0, $this->validator()->validate($query));
|
||||||
|
|
||||||
|
// Null is what puts the endpoint into full-span mode; there is no separate predicate for it.
|
||||||
|
self::assertNull($query->dateFromDate());
|
||||||
|
self::assertNull($query->dateToDate());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @dataProvider invalidCalendarQueries
|
* @dataProvider invalidCalendarQueries
|
||||||
*/
|
*/
|
||||||
@@ -33,6 +44,8 @@ class ContingentQueryTest extends TestCase
|
|||||||
yield 'reversed range' => [new ContingentCalendarQuery('HOTEL', '2026-03-02', '2026-03-01')];
|
yield 'reversed range' => [new ContingentCalendarQuery('HOTEL', '2026-03-02', '2026-03-01')];
|
||||||
yield 'range over 366 days' => [new ContingentCalendarQuery('HOTEL', '2026-01-01', '2027-01-03')];
|
yield 'range over 366 days' => [new ContingentCalendarQuery('HOTEL', '2026-01-01', '2027-01-03')];
|
||||||
yield 'invalid hotel code' => [new ContingentCalendarQuery('HOTEL CODE', '2026-01-01', '2026-01-02')];
|
yield 'invalid hotel code' => [new ContingentCalendarQuery('HOTEL CODE', '2026-01-01', '2026-01-02')];
|
||||||
|
yield 'dateFrom without dateTo' => [new ContingentCalendarQuery('HOTEL', '2026-01-01')];
|
||||||
|
yield 'dateTo without dateFrom' => [new ContingentCalendarQuery('HOTEL', null, '2026-01-02')];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPricesQueryRequiresFourDigitYear(): void
|
public function testPricesQueryRequiresFourDigitYear(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user