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

+36
View File
@@ -79,4 +79,40 @@ class CmsDataProviderTest extends TestCase
self::assertNull($provider->getHotelDetails('UNKNOWN'));
}
public function testFetchCalendarHotelsKeepsCodeAndName(): void
{
$client = new MockHttpClient(function (string $method, string $url) {
self::assertSame('https://cms.test/api/calendar-hotels', $url);
return new MockResponse(json_encode([
['code' => 'ASCGR', 'name' => 'Ahrntal - Gruppenhaus Christiler', 'products' => [['uid' => 710, 'name' => 'Gruppenhaus Christiler', 'season' => 'w']]],
['code' => 'DKI', 'name' => '', 'products' => []],
['code' => '', 'name' => 'No code'],
['name' => 'Missing code'],
], JSON_THROW_ON_ERROR));
}, 'https://cms.test/');
$hotels = (new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels();
self::assertNotNull($hotels);
self::assertSame([['ASCGR', 'Ahrntal - Gruppenhaus Christiler'], ['DKI', 'DKI']], array_map(
static fn ($hotel) => [$hotel->code, $hotel->name],
$hotels,
));
}
public function testFetchCalendarHotelsIsNullOnAnErrorResponse(): void
{
$client = new MockHttpClient(new MockResponse('Forbidden', ['http_code' => 403]), 'https://cms.test/');
self::assertNull((new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels());
}
public function testFetchCalendarHotelsIsNullWhenTheBodyIsNotAList(): void
{
$client = new MockHttpClient(new MockResponse('{"success":false}'), 'https://cms.test/');
self::assertNull((new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels());
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\CalendarHotel;
use App\Enum\ContingentHotelSource;
use App\Model\CmsCalendarHotel;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\CalendarHotelRepository;
use App\Service\CmsDataProvider;
use App\Service\ContingentHotelDirectory;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ContingentHotelDirectoryTest extends TestCase
{
private CmsDataProvider&Stub $cmsDataProvider;
private AccommodationRepository&Stub $accommodationRepository;
private CalendarHotelRepository&Stub $calendarHotelRepository;
private EntityManagerInterface&Stub $entityManager;
protected function setUp(): void
{
$this->cmsDataProvider = $this->createStub(CmsDataProvider::class);
$this->accommodationRepository = $this->createStub(AccommodationRepository::class);
$this->calendarHotelRepository = $this->createStub(CalendarHotelRepository::class);
$this->entityManager = $this->createStub(EntityManagerInterface::class);
}
public function testRefreshAddsRenamesAndRemovesStoredHotels(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$renamed = (new CalendarHotel())->setCode('DKI')->setName('Schwendi');
$dropped = (new CalendarHotel())->setCode('ASB')->setName('Sportclub Bader');
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn(['DKI' => $renamed, 'ASB' => $dropped]);
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn([
new CmsCalendarHotel('DKI', 'Sportclub Schwendi'),
new CmsCalendarHotel('DWW', 'Sportclub Weissfluh'),
]);
$this->entityManager->expects(self::once())->method('persist')
->with(self::callback(static fn (CalendarHotel $hotel) => 'DWW' === $hotel->getCode() && 'Sportclub Weissfluh' === $hotel->getName()));
$this->entityManager->expects(self::once())->method('remove')->with($dropped);
$this->entityManager->expects(self::once())->method('flush');
self::assertTrue($this->directory()->refresh());
self::assertSame('Sportclub Schwendi', $renamed->getName());
}
public function testRefreshKeepsTheStoredListWhenTypo3IsUnavailable(): void
{
$this->assertKeepsStoredList(null);
}
public function testRefreshKeepsTheStoredListWhenTypo3ReturnsNoHotels(): void
{
$this->assertKeepsStoredList([]);
}
public function testRefreshIgnoresCodesTheApiCouldNotBeAskedFor(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([]);
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn([
new CmsCalendarHotel('DKI', 'Sportclub Schwendi'),
new CmsCalendarHotel('ABCDEFGHIJKLMNOPQ', 'Too long'),
new CmsCalendarHotel('A B', 'Space'),
]);
$this->entityManager->expects(self::once())->method('persist')
->with(self::callback(static fn (CalendarHotel $hotel) => 'DKI' === $hotel->getCode()));
$this->directory()->refresh();
}
public function testAllIsTheUnionOfAccommodationsAndTypo3Hotels(): void
{
$this->accommodationRepository->method('findAllWithCalendarCode')->willReturn([
(new Accommodation())->setCalendarCode('DGS'),
(new Accommodation())->setCalendarCode('SERASCSO'),
]);
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([
'ASCGR' => (new CalendarHotel())->setCode('ASCGR')->setName('Christiler'),
'DGS' => (new CalendarHotel())->setCode('DGS')->setName('Spinabad'),
]);
$hotels = $this->directory()->all();
self::assertSame(['ASCGR', 'DGS', 'SERASCSO'], array_keys($hotels));
self::assertSame([ContingentHotelSource::Typo3], $hotels['ASCGR']->sources());
self::assertSame([ContingentHotelSource::Accommodation, ContingentHotelSource::Typo3], $hotels['DGS']->sources());
self::assertSame([ContingentHotelSource::Accommodation], $hotels['SERASCSO']->sources());
}
public function testFindKnowsATypo3OnlyHotel(): void
{
$this->accommodationRepository->method('findOneByCalendarCode')->willReturn(null);
$this->calendarHotelRepository->method('findOneBy')->willReturn((new CalendarHotel())->setCode('DKI')->setName('Schwendi'));
self::assertSame('DKI', $this->directory()->find('DKI')?->code);
}
public function testFindReturnsNullForAnUnknownCode(): void
{
$this->accommodationRepository->method('findOneByCalendarCode')->willReturn(null);
$this->calendarHotelRepository->method('findOneBy')->willReturn(null);
self::assertNull($this->directory()->find('NOPE'));
}
/**
* @param list<CmsCalendarHotel>|null $fetched
*/
private function assertKeepsStoredList(?array $fetched): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([
'DKI' => (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi'),
]);
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn($fetched);
$this->entityManager->expects(self::never())->method('remove');
$this->entityManager->expects(self::never())->method('flush');
$logger->expects(self::once())->method('warning');
self::assertFalse($this->directory($logger)->refresh());
}
private function directory(?LoggerInterface $logger = null): ContingentHotelDirectory
{
return new ContingentHotelDirectory(
$this->cmsDataProvider,
$this->accommodationRepository,
$this->calendarHotelRepository,
$this->entityManager,
$logger ?? $this->createStub(LoggerInterface::class),
);
}
}
+30 -29
View File
@@ -10,7 +10,6 @@ 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;
@@ -40,16 +39,23 @@ class ContingentSnapshotManagerTest extends TestCase
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$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');
$persisted = [];
$this->entityManager->expects(self::exactly(3))->method('persist')
->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted[] = $entity;
});
$result = $this->sync();
$days = array_values(array_filter($persisted, static fn (object $entity) => $entity instanceof ContingentDay));
self::assertSame(['HOTEL1', 'HOTEL1'], array_map(static fn (ContingentDay $day) => $day->getHotelCode(), $days));
self::assertTrue($result->successful);
self::assertTrue($result->changed);
self::assertSame(2, $result->added);
@@ -67,7 +73,7 @@ class ContingentSnapshotManagerTest extends TestCase
'2026-07-03' => $this->day('2026-07-03', ContingentStatus::Ok),
];
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn($existing);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
@@ -85,10 +91,10 @@ class ContingentSnapshotManagerTest extends TestCase
public function testAnIdenticalUpstreamResponseDoesNotMoveChangedAt(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
@@ -107,11 +113,11 @@ class ContingentSnapshotManagerTest extends TestCase
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$state = new ContingentSyncState('HOTEL1');
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->client->method('getContingentCalendar')->willThrowException(new BpnConnectException('upstream down'));
$this->dayRepository->expects(self::never())->method('findByAccommodationAndDateRange');
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
$this->entityManager->expects(self::never())->method('remove');
$result = $this->sync();
@@ -126,9 +132,9 @@ class ContingentSnapshotManagerTest extends TestCase
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
$state = new ContingentSyncState('HOTEL1');
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->client->method('getContingentCalendar')->willReturn($this->response([]));
@@ -143,7 +149,7 @@ class ContingentSnapshotManagerTest extends TestCase
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
{
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
@@ -157,11 +163,11 @@ class ContingentSnapshotManagerTest extends TestCase
public function testNearTermSyncDoesNotShrinkTheHorizonEstablishedByTheFullRun(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2028-08-20'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
@@ -174,11 +180,11 @@ class ContingentSnapshotManagerTest extends TestCase
public function testHorizonGrowsWhenAFurtherWindowIsSynced(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2026-01-01'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
@@ -190,11 +196,11 @@ class ContingentSnapshotManagerTest extends TestCase
public function testTheRollingWindowGrowingIsNotAChange(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$state->recordSuccess(new \DateTimeImmutable('2026-06-30'), new \DateTimeImmutable('2026-07-02'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
]);
@@ -225,17 +231,12 @@ class ContingentSnapshotManagerTest extends TestCase
);
return $manager->sync(
$this->accommodation(),
'HOTEL1',
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())
+23 -10
View File
@@ -5,7 +5,6 @@ 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;
@@ -31,7 +30,7 @@ class ContingentSnapshotReaderTest extends TestCase
{
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->syncStateRepository->method('findOneByAccommodation')->willReturn(null);
$this->syncStateRepository->method('findOneByHotelCode')->willReturn(null);
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
self::assertNull($this->read());
@@ -41,7 +40,7 @@ class ContingentSnapshotReaderTest extends TestCase
{
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-7 hours'));
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-7 hours'));
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
self::assertNull($this->read());
@@ -49,7 +48,7 @@ class ContingentSnapshotReaderTest extends TestCase
public function testReturnsStatusesWhenRecentlySynced(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-10 minutes'));
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-10 minutes'));
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-09-01' => $this->day(ContingentStatus::Ok),
'2026-09-02' => $this->day(ContingentStatus::OnRequest),
@@ -63,25 +62,39 @@ class ContingentSnapshotReaderTest extends TestCase
public function testStillUsableJustInsideTheStalenessWindow(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-5 hours'));
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-5 hours'));
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
self::assertSame([], $this->read());
}
public function testReturnsNullForAnAccommodationWithoutACalendarCode(): void
public function testReturnsNullForAnEmptyHotelCode(): void
{
$this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
$this->syncStateRepository->expects(self::never())->method('findOneByAccommodation');
$this->syncStateRepository->expects(self::never())->method('findOneByHotelCode');
self::assertNull($this->read(new Accommodation()));
self::assertNull($this->read(''));
}
public function testIsCurrentMatchesWhatStatusesForServes(): void
{
$reader = new ContingentSnapshotReader(
$this->dayRepository,
$this->syncStateRepository,
$this->createStub(LoggerInterface::class),
);
self::assertFalse($reader->isCurrent(null));
self::assertFalse($reader->isCurrent(new ContingentSyncState('HOTEL1')));
self::assertFalse($reader->isCurrent($this->state('-7 hours')));
self::assertTrue($reader->isCurrent($this->state('-5 hours')));
}
/**
* @return array<string, ContingentStatus>|null
*/
private function read(?Accommodation $accommodation = null): ?array
private function read(string $hotelCode = 'HOTEL1'): ?array
{
$reader = new ContingentSnapshotReader(
$this->dayRepository,
@@ -90,7 +103,7 @@ class ContingentSnapshotReaderTest extends TestCase
);
return $reader->statusesFor(
$accommodation ?? (new Accommodation())->setCalendarCode('HOTEL1'),
$hotelCode,
new \DateTimeImmutable('2026-09-01'),
new \DateTimeImmutable('2026-09-03'),
);