feat: improved contingents api performance with db backed snapshots
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\BpnSyncContingentsCommand;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Model\ContingentSyncResult;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\ContingentSnapshotManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* The exit code drives zenstruck's failure mail, and this task runs every 15 minutes — so what
|
||||
* counts as "failed" is a deliberate decision, not an implementation detail.
|
||||
*/
|
||||
class BpnSyncContingentsCommandTest extends TestCase
|
||||
{
|
||||
public function testTotalFailureFailsTheTask(): void
|
||||
{
|
||||
$tester = $this->runSync(['A', 'B'], [
|
||||
ContingentSyncResult::failed('upstream down'),
|
||||
ContingentSyncResult::failed('upstream down'),
|
||||
]);
|
||||
|
||||
self::assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testASingleFailingHotelDoesNotFailTheTask(): void
|
||||
{
|
||||
$tester = $this->runSync(['A', 'B', 'C'], [
|
||||
ContingentSyncResult::synced(true, 3, 0, 0),
|
||||
ContingentSyncResult::failed('unknown hotel code'),
|
||||
ContingentSyncResult::synced(false, 0, 0, 0),
|
||||
]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString('B', $tester->getDisplay());
|
||||
self::assertStringContainsString('unknown hotel code', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testASuccessfulRunSucceeds(): void
|
||||
{
|
||||
$tester = $this->runSync(['A'], [ContingentSyncResult::synced(true, 3, 1, 0)]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testARunWithNoAccommodationsSucceeds(): void
|
||||
{
|
||||
$tester = $this->runSync([], []);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $hotelCodes
|
||||
* @param ContingentSyncResult[] $results
|
||||
*/
|
||||
private function runSync(array $hotelCodes, array $results): CommandTester
|
||||
{
|
||||
$accommodations = array_map(
|
||||
static fn (string $code) => (new Accommodation())->setCalendarCode($code),
|
||||
$hotelCodes,
|
||||
);
|
||||
|
||||
$accommodationRepository = $this->createMock(AccommodationRepository::class);
|
||||
$accommodationRepository->method('findAllWithCalendarCode')->willReturn($accommodations);
|
||||
|
||||
$manager = $this->createMock(ContingentSnapshotManager::class);
|
||||
if ([] !== $results) {
|
||||
$manager->method('sync')->willReturnOnConsecutiveCalls(...$results);
|
||||
}
|
||||
|
||||
$command = new BpnSyncContingentsCommand(
|
||||
$accommodationRepository,
|
||||
$this->createMock(ContingentSyncStateRepository::class),
|
||||
$this->createMock(ContingentDayRepository::class),
|
||||
$manager,
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$application = new Application();
|
||||
$application->add($command);
|
||||
|
||||
$tester = new CommandTester($application->find('app:bpn:sync-contingents'));
|
||||
$tester->execute(['--horizon-months' => '1']);
|
||||
|
||||
// LockableTrait's lock lives for the life of the process, so without releasing it every
|
||||
// run after the first would exit early and these assertions would pass for the wrong reason.
|
||||
$release = new \ReflectionMethod($command, 'release');
|
||||
$release->invoke($command);
|
||||
|
||||
return $tester;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BpnConnect\ContingentsClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\ContingentCalendarEntry;
|
||||
use App\BpnConnect\Model\ContingentCalendarMeta;
|
||||
use App\BpnConnect\Model\ContingentCalendarResponse;
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\ContingentSnapshotManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class ContingentSnapshotManagerTest extends TestCase
|
||||
{
|
||||
private ContingentsClient&MockObject $client;
|
||||
private ContingentDayRepository&MockObject $dayRepository;
|
||||
private ContingentSyncStateRepository&MockObject $syncStateRepository;
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = $this->createMock(ContingentsClient::class);
|
||||
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
|
||||
$this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
public function testAddsMissingDaysAndMarksSnapshotChanged(): void
|
||||
{
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK', '2026-07-02:BLOCKED']);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
|
||||
]));
|
||||
|
||||
$this->entityManager->expects(self::exactly(3))->method('persist');
|
||||
|
||||
$result = $this->sync();
|
||||
|
||||
self::assertTrue($result->successful);
|
||||
self::assertTrue($result->changed);
|
||||
self::assertSame(2, $result->added);
|
||||
self::assertSame(0, $result->updated);
|
||||
self::assertSame(0, $result->removed);
|
||||
}
|
||||
|
||||
public function testUpdatesChangedStatusAndRemovesVanishedDays(): void
|
||||
{
|
||||
$existing = [
|
||||
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
|
||||
'2026-07-03' => $this->day('2026-07-03', ContingentStatus::Ok),
|
||||
];
|
||||
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
|
||||
]));
|
||||
|
||||
$this->entityManager->expects(self::once())->method('remove');
|
||||
|
||||
$result = $this->sync();
|
||||
|
||||
self::assertSame(0, $result->added);
|
||||
self::assertSame(1, $result->updated);
|
||||
self::assertSame(1, $result->removed);
|
||||
self::assertSame(ContingentStatus::OnRequest, $existing['2026-07-02']->getStatus());
|
||||
}
|
||||
|
||||
public function testUnchangedFingerprintDoesNotMoveChangedAt(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$state->setContentHash(hash('sha256', '2026-07-01:OK'));
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
||||
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||
]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn(['2026-07-01:OK']);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
]));
|
||||
|
||||
$result = $this->sync();
|
||||
|
||||
self::assertFalse($result->changed);
|
||||
self::assertNull($state->getChangedAt());
|
||||
self::assertNotNull($state->getSyncedAt());
|
||||
}
|
||||
|
||||
public function testUpstreamFailureKeepsSnapshotAndRecordsError(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->client->method('getContingentCalendar')->willThrowException(new BpnConnectException('upstream down'));
|
||||
|
||||
$this->dayRepository->expects(self::never())->method('findByAccommodationAndDateRange');
|
||||
$this->entityManager->expects(self::never())->method('remove');
|
||||
|
||||
$result = $this->sync();
|
||||
|
||||
self::assertFalse($result->successful);
|
||||
self::assertSame('upstream down', $result->error);
|
||||
self::assertSame(1, $state->getFailureCount());
|
||||
self::assertNull($state->getSyncedAt());
|
||||
}
|
||||
|
||||
public function testEmptyUpstreamResponseDoesNotWipeAPopulatedRange(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
|
||||
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
|
||||
]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([]));
|
||||
|
||||
$this->entityManager->expects(self::never())->method('remove');
|
||||
|
||||
$result = $this->sync();
|
||||
|
||||
self::assertFalse($result->successful);
|
||||
self::assertSame(1, $state->getFailureCount());
|
||||
}
|
||||
|
||||
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
|
||||
{
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
new ContingentCalendarEntry('2026-08-01', ContingentStatus::Ok),
|
||||
]));
|
||||
|
||||
$result = $this->sync();
|
||||
|
||||
self::assertSame(1, $result->added);
|
||||
}
|
||||
|
||||
public function testNearTermSyncDoesNotShrinkTheHorizonEstablishedByTheFullRun(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2028-08-20'));
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
]));
|
||||
|
||||
// The near-term job syncs only to 2026-07-03 but must not discard the 24-month reach.
|
||||
$this->sync();
|
||||
|
||||
self::assertSame('2028-08-20', $state->getHorizonTo()?->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testHorizonGrowsWhenAFurtherWindowIsSynced(): void
|
||||
{
|
||||
$state = new ContingentSyncState($this->accommodation());
|
||||
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2026-01-01'));
|
||||
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
|
||||
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
|
||||
$this->dayRepository->method('findStatusFingerprintParts')->willReturn([]);
|
||||
$this->client->method('getContingentCalendar')->willReturn($this->response([
|
||||
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
|
||||
]));
|
||||
|
||||
$this->sync();
|
||||
|
||||
self::assertSame('2026-07-03', $state->getHorizonTo()?->format('Y-m-d'));
|
||||
}
|
||||
|
||||
private function sync(): \App\Model\ContingentSyncResult
|
||||
{
|
||||
$manager = new ContingentSnapshotManager(
|
||||
$this->client,
|
||||
$this->dayRepository,
|
||||
$this->syncStateRepository,
|
||||
$this->entityManager,
|
||||
$this->createMock(LoggerInterface::class),
|
||||
);
|
||||
|
||||
return $manager->sync(
|
||||
$this->accommodation(),
|
||||
new \DateTimeImmutable('2026-07-01'),
|
||||
new \DateTimeImmutable('2026-07-03'),
|
||||
);
|
||||
}
|
||||
|
||||
private function accommodation(): Accommodation
|
||||
{
|
||||
return (new Accommodation())->setCalendarCode('HOTEL1');
|
||||
}
|
||||
|
||||
private function day(string $date, ContingentStatus $status): ContingentDay
|
||||
{
|
||||
return (new ContingentDay())
|
||||
->setDate(new \DateTimeImmutable($date))
|
||||
->setStatus($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ContingentCalendarEntry[] $entries
|
||||
*/
|
||||
private function response(array $entries): ContingentCalendarResponse
|
||||
{
|
||||
return new ContingentCalendarResponse(
|
||||
new ContingentCalendarMeta('2026-07-01', '2026-07-03', 'days', 'HOTEL1', 1, count($entries)),
|
||||
$entries,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BpnConnect\Model\ContingentStatus;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\ContingentDay;
|
||||
use App\Entity\Groups\ContingentSyncState;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\ContingentSnapshotReader;
|
||||
use Carbon\CarbonImmutable;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class ContingentSnapshotReaderTest extends TestCase
|
||||
{
|
||||
private ContingentDayRepository&MockObject $dayRepository;
|
||||
private ContingentSyncStateRepository&MockObject $syncStateRepository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
|
||||
$this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
|
||||
}
|
||||
|
||||
public function testReturnsNullWhenNothingHasEverSynced(): void
|
||||
{
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn(null);
|
||||
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
|
||||
|
||||
self::assertNull($this->read());
|
||||
}
|
||||
|
||||
public function testReturnsNullWhenTheSnapshotIsStale(): void
|
||||
{
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-7 hours'));
|
||||
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
|
||||
|
||||
self::assertNull($this->read());
|
||||
}
|
||||
|
||||
public function testReturnsStatusesWhenRecentlySynced(): void
|
||||
{
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-10 minutes'));
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
|
||||
'2026-09-01' => $this->day(ContingentStatus::Ok),
|
||||
'2026-09-02' => $this->day(ContingentStatus::OnRequest),
|
||||
]);
|
||||
|
||||
self::assertSame([
|
||||
'2026-09-01' => ContingentStatus::Ok,
|
||||
'2026-09-02' => ContingentStatus::OnRequest,
|
||||
], $this->read());
|
||||
}
|
||||
|
||||
public function testStillUsableJustInsideTheStalenessWindow(): void
|
||||
{
|
||||
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-5 hours'));
|
||||
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
|
||||
|
||||
self::assertSame([], $this->read());
|
||||
}
|
||||
|
||||
public function testReturnsNullForAnAccommodationWithoutACalendarCode(): void
|
||||
{
|
||||
$this->syncStateRepository->expects(self::never())->method('findOneByAccommodation');
|
||||
|
||||
self::assertNull($this->read(new Accommodation()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ContingentStatus>|null
|
||||
*/
|
||||
private function read(?Accommodation $accommodation = null): ?array
|
||||
{
|
||||
$reader = new ContingentSnapshotReader(
|
||||
$this->dayRepository,
|
||||
$this->syncStateRepository,
|
||||
$this->createMock(LoggerInterface::class),
|
||||
);
|
||||
|
||||
return $reader->statusesFor(
|
||||
$accommodation ?? (new Accommodation())->setCalendarCode('HOTEL1'),
|
||||
new \DateTimeImmutable('2026-09-01'),
|
||||
new \DateTimeImmutable('2026-09-03'),
|
||||
);
|
||||
}
|
||||
|
||||
private function state(string $syncedAgo): ContingentSyncState
|
||||
{
|
||||
return (new ContingentSyncState())->setSyncedAt(CarbonImmutable::now()->modify($syncedAgo)->toDateTimeImmutable());
|
||||
}
|
||||
|
||||
private function day(ContingentStatus $status): ContingentDay
|
||||
{
|
||||
return (new ContingentDay())->setStatus($status);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user