Files
myep/tests/Controller/Api/ContingentControllerTest.php
T

514 lines
21 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\Entity\Groups\CalendarHotel;
use App\Entity\Groups\ContingentSyncState;
use App\Model\ContingentCalendarQuery;
use App\Model\ContingentHotel;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\ContingentDayRepository;
use App\Repository\Groups\ContingentSyncStateRepository;
use App\Service\AccommodationPriceCoverage;
use App\Service\ContingentHotelDirectory;
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);
}
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
{
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('find')->willReturn(null);
$response = $this->callAvailability([], hotelDirectory: $hotelDirectory);
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
self::assertSame('{"error":"Hotel not found for hotelCode."}', $response->getContent());
}
public function testAvailabilityServesATypo3HotelWithoutAnAccommodation(): void
{
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('find')->willReturn(new ContingentHotel('DKI', null, (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi')));
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
$snapshotReader->expects(self::once())
->method('statusesFor')
->with('DKI', self::anything(), self::anything())
->willReturn(['2026-09-01' => ContingentStatus::Ok]);
$response = $this->callAvailability(null, new ContingentCalendarQuery('DKI', '2026-09-01', '2026-09-03'), $snapshotReader, $hotelDirectory);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('[{"date":"2026-09-01","status":"OK"}]', $response->getContent());
}
public function testStatusListsEverySyncedHotel(): void
{
CarbonImmutable::setTestNow('2026-10-29 10:30:00');
$accommodation = (new Accommodation())->setName('Gruppenhaus Spinabad')->setCalendarCode('DGS')->setCmsCode('SERDGS');
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('all')->willReturn([
'DGS' => new ContingentHotel('DGS', $accommodation, (new CalendarHotel())->setCode('DGS')->setName('Sportclub Spinabad')),
'DKI' => new ContingentHotel('DKI', null, (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi')),
'DWW' => new ContingentHotel('DWW', null, (new CalendarHotel())->setCode('DWW')->setName('Sportclub Weissfluh')),
]);
$syncStateRepository = $this->createStub(ContingentSyncStateRepository::class);
$syncStateRepository->method('findAllIndexedByHotelCode')->willReturn([
'DGS' => (new ContingentSyncState('DGS'))->recordSuccess(new \DateTimeImmutable('2026-10-29 10:15:00+01:00'), new \DateTimeImmutable('2028-10-29')),
'DKI' => (new ContingentSyncState('DKI'))->recordFailure('unknown hotel code'),
]);
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
$priceRepository->expects(self::once())
->method('findPricedDateBoundsByHotelCode')
->with('DGS')
->willReturn(['from' => new \DateTimeImmutable('2027-06-01'), 'to' => new \DateTimeImmutable('2027-09-30')]);
$controller = new ContingentController(
$this->createStub(AccommodationRepository::class),
$priceRepository,
new ContingentSnapshotReader(
$this->createStub(ContingentDayRepository::class),
$syncStateRepository,
$this->createStub(LoggerInterface::class),
),
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
$hotelDirectory,
$syncStateRepository,
$this->createStub(LoggerInterface::class),
);
$controller->setContainer(new Container());
$data = json_decode((string) $controller->status()->getContent(), true);
self::assertSame([
[
'hotelCode' => 'DGS',
'name' => 'Sportclub Spinabad',
'sources' => ['accommodation', 'typo3'],
'accommodation' => ['name' => 'Gruppenhaus Spinabad', 'cmsCode' => 'SERDGS'],
'pricedFrom' => '2027-06-01',
'pricedTo' => '2027-09-30',
'syncedAt' => '2026-10-29T10:15:00+01:00',
'lastError' => null,
'failureCount' => 0,
'available' => true,
],
[
'hotelCode' => 'DKI',
'name' => 'Sportclub Schwendi',
'sources' => ['typo3'],
'accommodation' => null,
'pricedFrom' => null,
'pricedTo' => null,
'syncedAt' => null,
'lastError' => 'unknown hotel code',
'failureCount' => 1,
'available' => false,
],
[
'hotelCode' => 'DWW',
'name' => 'Sportclub Weissfluh',
'sources' => ['typo3'],
'accommodation' => null,
'pricedFrom' => null,
'pricedTo' => null,
'syncedAt' => null,
'lastError' => null,
'failureCount' => 0,
'available' => false,
],
], $data);
}
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<string, ContingentStatus>|null $statuses
*/
private function callAvailability(
?array $statuses,
?ContingentCalendarQuery $query = null,
?ContingentSnapshotReader $snapshotReader = null,
?ContingentHotelDirectory $hotelDirectory = null,
): Response {
if (null === $hotelDirectory) {
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('find')->willReturn(new ContingentHotel('HOTEL1', (new Accommodation())->setCalendarCode('HOTEL1'), null));
}
// The hotel is looked up in the directory: an accommodation is not required here.
$accommodationRepository = $this->createMock(AccommodationRepository::class);
$accommodationRepository->expects(self::never())->method(self::anything());
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)),
$hotelDirectory,
$this->createStub(ContingentSyncStateRepository::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
* @param array<string, ContingentStatus> $statuses
*
* @return array<int, array<string, mixed>>
*/
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<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->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(ContingentHotelDirectory::class),
$this->createStub(ContingentSyncStateRepository::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(ContingentHotelDirectory::class),
$this->createStub(ContingentSyncStateRepository::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<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');
}
}