feat: improved contingents api performance with db backed snapshots

This commit is contained in:
Björn Fromme
2026-08-20 11:46:03 +02:00
parent d5f9f4ef10
commit 351fbab498
20 changed files with 1521 additions and 109 deletions
@@ -4,16 +4,20 @@ declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\BpnConnect\ContingentsClient;
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 PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Response;
class ContingentControllerTest extends TestCase
{
@@ -44,13 +48,75 @@ class ContingentControllerTest extends TestCase
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'));
}
/**
* @param array<string, ContingentStatus>|null $statuses
*/
private function callCalendar(?array $statuses): 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);
$controller = new ContingentController(
$accommodationRepository,
$this->createMock(AccommodationPriceRepository::class),
$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(new ContingentCalendarQuery('HOTEL1', '2026-09-01', '2026-09-03'));
}
private function createController(): ContingentController
{
return new ContingentController(
$this->createMock(ContingentsClient::class),
$this->createMock(AccommodationRepository::class),
$this->createMock(AccommodationPriceRepository::class),
$this->createMock(CacheInterface::class),
$this->createMock(ContingentSnapshotReader::class),
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)),
$this->createMock(LoggerInterface::class),
@@ -4,22 +4,19 @@ declare(strict_types=1);
namespace App\Tests\Controller\Groups\Booking;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Model\ContingentCalendarEntry;
use App\BpnConnect\Model\ContingentCalendarMeta;
use App\BpnConnect\Model\ContingentCalendarResponse;
use App\BpnConnect\Model\ContingentStatus;
use App\Controller\Groups\Booking\Step1Controller;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationPrice;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationPriceCoverage;
use App\Service\AccommodationSessionManager;
use App\Service\CalendarGridBuilder;
use App\Service\ContingentSnapshotReader;
use App\Service\GroupsPriceCalculator;
use App\Service\PriceTimelineBuilder;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Cache\CacheInterface;
class Step1CalendarDataTest extends TestCase
{
@@ -32,7 +29,7 @@ class Step1CalendarDataTest extends TestCase
$price->setMinNights(2);
$enriched = $this->buildEnrichedDayData(
$this->calendarResponse([
[
'2026-06-01' => ContingentStatus::Ok,
'2026-06-02' => ContingentStatus::Ok,
'2026-06-03' => ContingentStatus::Ok,
@@ -40,7 +37,7 @@ class Step1CalendarDataTest extends TestCase
'2026-06-05' => ContingentStatus::Ok,
'2026-06-06' => ContingentStatus::Ok,
'2026-06-07' => ContingentStatus::Ok,
]),
],
[$price],
'2026-06-01',
'2026-06-07',
@@ -68,11 +65,11 @@ class Step1CalendarDataTest extends TestCase
$price->setMinNights(1);
$enriched = $this->buildEnrichedDayData(
$this->calendarResponse([
[
'2026-06-01' => ContingentStatus::Ok,
'2026-06-02' => ContingentStatus::Blocked,
'2026-06-03' => ContingentStatus::Ok,
]),
],
[$price],
'2026-06-01',
'2026-06-03',
@@ -83,7 +80,7 @@ class Step1CalendarDataTest extends TestCase
self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']);
}
public function testPriceCoverageStillAppliesWhenContingentApiIsUnavailable(): void
public function testPriceCoverageStillAppliesWhenThereIsNoUsableSnapshot(): void
{
$price = new AccommodationPrice();
$price->setDateFrom(new \DateTimeImmutable('2026-06-01'));
@@ -98,38 +95,19 @@ class Step1CalendarDataTest extends TestCase
}
/**
* @param array<string, ContingentStatus> $statuses
*/
private function calendarResponse(array $statuses): ContingentCalendarResponse
{
$entries = [];
foreach ($statuses as $date => $status) {
$entries[] = new ContingentCalendarEntry(date: $date, status: $status);
}
return new ContingentCalendarResponse(
new ContingentCalendarMeta('', '', 'days', 'HOTEL', 1, count($entries)),
$entries,
);
}
/**
* @param AccommodationPrice[] $prices
* @param array<string, ContingentStatus>|null $statuses null = no usable snapshot
* @param AccommodationPrice[] $prices
*
* @return array<string, array{status: string, minNights: int}>
*/
private function buildEnrichedDayData(
?ContingentCalendarResponse $calendar,
?array $statuses,
array $prices,
string $dateFrom,
string $dateTo,
): array {
$cache = $this->createMock(CacheInterface::class);
if (null === $calendar) {
$cache->method('get')->willThrowException(new \App\BpnConnect\Exception\BpnConnectException('down'));
} else {
$cache->method('get')->willReturn($calendar);
}
$snapshotReader = $this->createMock(ContingentSnapshotReader::class);
$snapshotReader->method('statusesFor')->willReturn($statuses);
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices);
@@ -137,10 +115,9 @@ class Step1CalendarDataTest extends TestCase
$controller = new Step1Controller(
$this->createMock(AccommodationBookingService::class),
$this->createMock(AccommodationSessionManager::class),
$this->createMock(ContingentsClient::class),
$priceRepository,
new PriceTimelineBuilder(),
$cache,
$snapshotReader,
$this->createMock(CalendarGridBuilder::class),
$this->createMock(GroupsPriceCalculator::class),
new AccommodationPriceCoverage($priceRepository),
@@ -150,11 +127,10 @@ class Step1CalendarDataTest extends TestCase
return $method->invoke(
$controller,
(new Accommodation())->setCalendarCode('HOTEL'),
'HOTEL',
new \DateTimeImmutable($dateFrom),
new \DateTimeImmutable($dateTo),
$dateFrom,
$dateTo,
);
}
}
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Tests\Controller\Groups\Booking;
use App\BpnConnect\ContingentsClient;
use App\Controller\Groups\Booking\Step1Controller;
use App\Entity\Groups\Accommodation;
use App\Form\Model\AccommodationBookingDto;
@@ -13,6 +12,7 @@ use App\Service\AccommodationBookingService;
use App\Service\AccommodationPriceCoverage;
use App\Service\AccommodationSessionManager;
use App\Service\CalendarGridBuilder;
use App\Service\ContingentSnapshotReader;
use App\Service\GroupsPriceCalculator;
use App\Service\PriceTimelineBuilder;
use PHPUnit\Framework\TestCase;
@@ -21,7 +21,6 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Contracts\Cache\CacheInterface;
class Step1ControllerTest extends TestCase
{
@@ -111,10 +110,9 @@ class Step1ControllerTest extends TestCase
return new TestableAccommodationStep1Controller(
$bookingService,
$sessionManager,
$this->createMock(ContingentsClient::class),
$priceRepository,
new PriceTimelineBuilder(),
$this->createMock(CacheInterface::class),
$this->createMock(ContingentSnapshotReader::class),
new CalendarGridBuilder(),
new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]),
new AccommodationPriceCoverage($priceRepository),
@@ -127,10 +125,9 @@ final class TestableAccommodationStep1Controller extends Step1Controller
public function __construct(
AccommodationBookingService $bookingService,
AccommodationSessionManager $sessionManager,
ContingentsClient $contingentsClient,
AccommodationPriceRepository $priceRepository,
PriceTimelineBuilder $priceTimelineBuilder,
CacheInterface $cache,
ContingentSnapshotReader $snapshotReader,
CalendarGridBuilder $calendarGridBuilder,
GroupsPriceCalculator $priceCalculator,
AccommodationPriceCoverage $priceCoverage,
@@ -138,10 +135,9 @@ final class TestableAccommodationStep1Controller extends Step1Controller
parent::__construct(
$bookingService,
$sessionManager,
$contingentsClient,
$priceRepository,
$priceTimelineBuilder,
$cache,
$snapshotReader,
$calendarGridBuilder,
$priceCalculator,
$priceCoverage,