288 lines
11 KiB
PHP
288 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Controller\Api;
|
|
|
|
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 Carbon\CarbonImmutable;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\DependencyInjection\Container;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class ContingentControllerTest extends TestCase
|
|
{
|
|
protected function tearDown(): void
|
|
{
|
|
CarbonImmutable::setTestNow();
|
|
}
|
|
|
|
public function testEnrichEntryIncludesPricingMetadata(): void
|
|
{
|
|
$controller = $this->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<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 AccommodationPrice[]|null $prices
|
|
*/
|
|
private function callCalendar(
|
|
?array $statuses,
|
|
?ContingentCalendarQuery $query = null,
|
|
?array $prices = null,
|
|
?AccommodationPriceRepository $priceRepository = null,
|
|
): 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);
|
|
|
|
$priceRepository ??= $this->createMock(AccommodationPriceRepository::class);
|
|
|
|
if (null !== $prices) {
|
|
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices);
|
|
}
|
|
|
|
$controller = new ContingentController(
|
|
$accommodationRepository,
|
|
$priceRepository,
|
|
$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($query ?? new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03'));
|
|
}
|
|
|
|
private function createController(): ContingentController
|
|
{
|
|
return new ContingentController(
|
|
$this->createMock(AccommodationRepository::class),
|
|
$this->createMock(AccommodationPriceRepository::class),
|
|
$this->createMock(ContingentSnapshotReader::class),
|
|
new PriceTimelineBuilder(),
|
|
new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)),
|
|
$this->createMock(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<string, true> $covered
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
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');
|
|
}
|
|
}
|