feat: sync contingents for TYPO3 calendar hotels

This commit is contained in:
fromme committed 2026-10-29 15:05:04 +01:00
1 parent ec36b61275
commit 97c6330a9a
24 files changed
+1067 -180

No files matched your search

@@ -8,10 +8,16 @@ 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;
@@ -216,15 +222,114 @@ class ContingentControllerTest extends TestCase
public function testAvailabilityRejectsAnUnknownHotel(): void
{
$accommodationRepository = $this->createStub(AccommodationRepository::class);
$accommodationRepository->method('findOneBy')->willReturn(null);
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
$hotelDirectory->method('find')->willReturn(null);
$response = $this->callAvailability([], accommodationRepository: $accommodationRepository);
$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');
@@ -263,13 +368,17 @@ class ContingentControllerTest extends TestCase
?array $statuses,
?ContingentCalendarQuery $query = null,
?ContingentSnapshotReader $snapshotReader = null,
?AccommodationRepository $accommodationRepository = null,
?ContingentHotelDirectory $hotelDirectory = null,
): Response {
if (null === $accommodationRepository) {
$accommodationRepository = $this->createStub(AccommodationRepository::class);
$accommodationRepository->method('findOneBy')->willReturn((new Accommodation())->setCalendarCode('HOTEL1')->setCurrency('EUR'));
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);
@@ -285,6 +394,8 @@ class ContingentControllerTest extends TestCase
$snapshotReader,
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
$hotelDirectory,
$this->createStub(ContingentSyncStateRepository::class),
$this->createStub(LoggerInterface::class),
);
$controller->setContainer(new Container());
@@ -343,6 +454,8 @@ class ContingentControllerTest extends TestCase
$snapshotReader,
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
$this->createStub(ContingentHotelDirectory::class),
$this->createStub(ContingentSyncStateRepository::class),
$this->createStub(LoggerInterface::class),
);
@@ -361,6 +474,8 @@ class ContingentControllerTest extends TestCase
$this->createStub(ContingentSnapshotReader::class),
new PriceTimelineBuilder(),
new AccommodationPriceCoverage($this->createStub(AccommodationPriceRepository::class)),
$this->createStub(ContingentHotelDirectory::class),
$this->createStub(ContingentSyncStateRepository::class),
$this->createStub(LoggerInterface::class),
);
}