fix: treat dates without price as unavailable
This commit is contained in:
@@ -10,6 +10,7 @@ use App\Entity\Groups\Accommodation;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\AccommodationSessionManager;
|
||||
use App\Service\CalendarGridBuilder;
|
||||
use App\Service\GroupsPriceCalculator;
|
||||
@@ -105,15 +106,18 @@ class Step1ControllerTest extends TestCase
|
||||
AccommodationBookingService $bookingService,
|
||||
AccommodationSessionManager $sessionManager,
|
||||
): TestableAccommodationStep1Controller {
|
||||
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
|
||||
|
||||
return new TestableAccommodationStep1Controller(
|
||||
$bookingService,
|
||||
$sessionManager,
|
||||
$this->createMock(ContingentsClient::class),
|
||||
$this->createMock(AccommodationPriceRepository::class),
|
||||
$priceRepository,
|
||||
new PriceTimelineBuilder(),
|
||||
$this->createMock(CacheInterface::class),
|
||||
new CalendarGridBuilder(),
|
||||
new GroupsPriceCalculator(new PriceTimelineBuilder(), ['runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0]),
|
||||
new AccommodationPriceCoverage($priceRepository),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +133,7 @@ final class TestableAccommodationStep1Controller extends Step1Controller
|
||||
CacheInterface $cache,
|
||||
CalendarGridBuilder $calendarGridBuilder,
|
||||
GroupsPriceCalculator $priceCalculator,
|
||||
AccommodationPriceCoverage $priceCoverage,
|
||||
) {
|
||||
parent::__construct(
|
||||
$bookingService,
|
||||
@@ -139,6 +144,7 @@ final class TestableAccommodationStep1Controller extends Step1Controller
|
||||
$cache,
|
||||
$calendarGridBuilder,
|
||||
$priceCalculator,
|
||||
$priceCoverage,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,22 +9,56 @@ use App\Controller\Api\ContingentController;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class ContingentControllerTest extends TestCase
|
||||
{
|
||||
public function testEnrichEntryIncludesPricingMetadata(): void
|
||||
{
|
||||
$controller = new ContingentController(
|
||||
$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']);
|
||||
}
|
||||
|
||||
private function createController(): ContingentController
|
||||
{
|
||||
return new ContingentController(
|
||||
$this->createMock(ContingentsClient::class),
|
||||
$this->createMock(AccommodationRepository::class),
|
||||
$this->createMock(AccommodationPriceRepository::class),
|
||||
$this->createMock(CacheInterface::class),
|
||||
new PriceTimelineBuilder(),
|
||||
new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class)),
|
||||
$this->createMock(LoggerInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function createPrice(): AccommodationPrice
|
||||
{
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable('2026-07-01'));
|
||||
$price->setDateTo(new \DateTimeImmutable('2026-07-31'));
|
||||
@@ -33,13 +67,24 @@ class ContingentControllerTest extends TestCase
|
||||
$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');
|
||||
|
||||
$result = $method->invoke($controller, '2026-07-06', 'available', [$price], 'EUR');
|
||||
|
||||
self::assertSame(4, $result['includedPax']);
|
||||
self::assertSame(3, $result['minNights']);
|
||||
self::assertSame(123.45, $result['pricePerNight']);
|
||||
self::assertSame('EUR', $result['currency']);
|
||||
return $method->invoke($controller, $date, $status, $prices, $covered, 'EUR');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Groups;
|
||||
|
||||
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\Step1Controller;
|
||||
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\GroupsPriceCalculator;
|
||||
use App\Service\PriceTimelineBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class Step1CalendarDataTest extends TestCase
|
||||
{
|
||||
public function testDaysWithoutPriceAreBlockedEvenWhenContingentIsOk(): void
|
||||
{
|
||||
// Contingent says OK for the whole week, prices only cover 03.–05.
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable('2026-06-03'));
|
||||
$price->setDateTo(new \DateTimeImmutable('2026-06-05'));
|
||||
$price->setMinNights(2);
|
||||
|
||||
$enriched = $this->buildEnrichedDayData(
|
||||
$this->calendarResponse([
|
||||
'2026-06-01' => ContingentStatus::Ok,
|
||||
'2026-06-02' => ContingentStatus::Ok,
|
||||
'2026-06-03' => ContingentStatus::Ok,
|
||||
'2026-06-04' => ContingentStatus::Ok,
|
||||
'2026-06-05' => ContingentStatus::Ok,
|
||||
'2026-06-06' => ContingentStatus::Ok,
|
||||
'2026-06-07' => ContingentStatus::Ok,
|
||||
]),
|
||||
[$price],
|
||||
'2026-06-01',
|
||||
'2026-06-07',
|
||||
);
|
||||
|
||||
self::assertSame('blocked', $enriched['2026-06-01']['status']);
|
||||
self::assertSame('blocked', $enriched['2026-06-02']['status']);
|
||||
// First priced night — arrival only
|
||||
self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']);
|
||||
self::assertSame('ok', $enriched['2026-06-04']['status']);
|
||||
self::assertSame('ok', $enriched['2026-06-05']['status']);
|
||||
// Day after the last priced night — still valid as checkout
|
||||
self::assertSame('checkout-only', $enriched['2026-06-06']['status']);
|
||||
self::assertSame('blocked', $enriched['2026-06-07']['status']);
|
||||
|
||||
self::assertSame(2, $enriched['2026-06-04']['minNights']);
|
||||
self::assertSame(0, $enriched['2026-06-07']['minNights']);
|
||||
}
|
||||
|
||||
public function testBlockedContingentWinsOverExistingPrice(): void
|
||||
{
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable('2026-06-01'));
|
||||
$price->setDateTo(new \DateTimeImmutable('2026-06-03'));
|
||||
$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',
|
||||
);
|
||||
|
||||
self::assertSame('blocked-to-ok', $enriched['2026-06-01']['status']);
|
||||
self::assertSame('checkout-only', $enriched['2026-06-02']['status']);
|
||||
self::assertSame('blocked-to-ok', $enriched['2026-06-03']['status']);
|
||||
}
|
||||
|
||||
public function testPriceCoverageStillAppliesWhenContingentApiIsUnavailable(): void
|
||||
{
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable('2026-06-01'));
|
||||
$price->setDateTo(new \DateTimeImmutable('2026-06-02'));
|
||||
$price->setMinNights(1);
|
||||
|
||||
$enriched = $this->buildEnrichedDayData(null, [$price], '2026-06-01', '2026-06-03');
|
||||
|
||||
self::assertSame('blocked-to-ok', $enriched['2026-06-01']['status']);
|
||||
self::assertSame('ok', $enriched['2026-06-02']['status']);
|
||||
self::assertSame('checkout-only', $enriched['2026-06-03']['status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
*
|
||||
* @return array<string, array{status: string, minNights: int}>
|
||||
*/
|
||||
private function buildEnrichedDayData(
|
||||
?ContingentCalendarResponse $calendar,
|
||||
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);
|
||||
}
|
||||
|
||||
$priceRepository = $this->createMock(AccommodationPriceRepository::class);
|
||||
$priceRepository->method('findByHotelCodeAndDateRange')->willReturn($prices);
|
||||
|
||||
$controller = new Step1Controller(
|
||||
$this->createMock(AccommodationBookingService::class),
|
||||
$this->createMock(AccommodationSessionManager::class),
|
||||
$this->createMock(ContingentsClient::class),
|
||||
$priceRepository,
|
||||
new PriceTimelineBuilder(),
|
||||
$cache,
|
||||
$this->createMock(CalendarGridBuilder::class),
|
||||
$this->createMock(GroupsPriceCalculator::class),
|
||||
new AccommodationPriceCoverage($priceRepository),
|
||||
);
|
||||
|
||||
$method = new \ReflectionMethod($controller, 'buildEnrichedDayData');
|
||||
|
||||
return $method->invoke(
|
||||
$controller,
|
||||
'HOTEL',
|
||||
new \DateTimeImmutable($dateFrom),
|
||||
new \DateTimeImmutable($dateTo),
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Service\AccommodationPriceCoverage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AccommodationPriceCoverageTest extends TestCase
|
||||
{
|
||||
public function testCoversOnlyDaysWithinPricePeriod(): void
|
||||
{
|
||||
$coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class));
|
||||
|
||||
$covered = $coverage->coveredDatesFor(
|
||||
[$this->price('2026-06-01', '2026-06-09')],
|
||||
new \DateTimeImmutable('2026-06-01'),
|
||||
new \DateTimeImmutable('2026-06-12'),
|
||||
);
|
||||
|
||||
// dateTo is the last night, so 2026-06-09 is still covered
|
||||
self::assertSame([
|
||||
'2026-06-01', '2026-06-02', '2026-06-03', '2026-06-04', '2026-06-05',
|
||||
'2026-06-06', '2026-06-07', '2026-06-08', '2026-06-09',
|
||||
], array_keys($covered));
|
||||
}
|
||||
|
||||
public function testGapBetweenPricePeriodsStaysUncovered(): void
|
||||
{
|
||||
$coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class));
|
||||
|
||||
$covered = $coverage->coveredDatesFor(
|
||||
[$this->price('2026-06-01', '2026-06-02'), $this->price('2026-06-05', '2026-06-06')],
|
||||
new \DateTimeImmutable('2026-06-01'),
|
||||
new \DateTimeImmutable('2026-06-06'),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
['2026-06-01', '2026-06-02', '2026-06-05', '2026-06-06'],
|
||||
array_keys($covered),
|
||||
);
|
||||
}
|
||||
|
||||
public function testNoPricesMeansNoCoverage(): void
|
||||
{
|
||||
$coverage = new AccommodationPriceCoverage($this->createMock(AccommodationPriceRepository::class));
|
||||
|
||||
$covered = $coverage->coveredDatesFor(
|
||||
[],
|
||||
new \DateTimeImmutable('2026-06-01'),
|
||||
new \DateTimeImmutable('2026-06-03'),
|
||||
);
|
||||
|
||||
self::assertSame([], $covered);
|
||||
}
|
||||
|
||||
private function price(string $from, string $to): AccommodationPrice
|
||||
{
|
||||
$price = new AccommodationPrice();
|
||||
$price->setDateFrom(new \DateTimeImmutable($from));
|
||||
$price->setDateTo(new \DateTimeImmutable($to));
|
||||
|
||||
return $price;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user