feat: sync contingents for TYPO3 calendar hotels
This commit is contained in:
1 parent
ec36b61275
commit
97c6330a9a
24 files changed
+1067
-180
No files matched your search
@@ -6,10 +6,12 @@ namespace App\Tests\Command;
|
||||
|
||||
use App\Command\BpnSyncContingentsCommand;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\CalendarHotel;
|
||||
use App\Model\ContingentHotel;
|
||||
use App\Model\ContingentSyncResult;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\Groups\ContingentDayRepository;
|
||||
use App\Repository\Groups\ContingentSyncStateRepository;
|
||||
use App\Service\ContingentHotelDirectory;
|
||||
use App\Service\ContingentSnapshotManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
@@ -53,36 +55,113 @@ class BpnSyncContingentsCommandTest extends TestCase
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testARunWithNoAccommodationsSucceeds(): void
|
||||
public function testARunWithNoHotelsSucceeds(): void
|
||||
{
|
||||
$tester = $this->runSync([], []);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $hotelCodes
|
||||
* @param ContingentSyncResult[] $results
|
||||
*/
|
||||
private function runSync(array $hotelCodes, array $results): CommandTester
|
||||
public function testSyncsAccommodationAndTypo3CodesAndDropsTheRest(): void
|
||||
{
|
||||
$accommodations = array_map(
|
||||
static fn (string $code) => (new Accommodation())->setCalendarCode($code),
|
||||
$hotelCodes,
|
||||
$manager = $this->createMock(ContingentSnapshotManager::class);
|
||||
$synced = [];
|
||||
$manager->expects(self::exactly(2))->method('sync')
|
||||
->willReturnCallback(static function (string $code) use (&$synced): ContingentSyncResult {
|
||||
$synced[] = $code;
|
||||
|
||||
return ContingentSyncResult::synced(false, 0, 0, 0, 0);
|
||||
});
|
||||
|
||||
$dayRepository = $this->createMock(ContingentDayRepository::class);
|
||||
$dayRepository->expects(self::once())->method('deleteExceptHotelCodes')->with(['DGS', 'DKI']);
|
||||
$syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
|
||||
$syncStateRepository->expects(self::once())->method('deleteExceptHotelCodes')->with(['DGS', 'DKI']);
|
||||
|
||||
$this->runSync(
|
||||
['DGS' => 'accommodation', 'DKI' => 'typo3'],
|
||||
manager: $manager,
|
||||
dayRepository: $dayRepository,
|
||||
syncStateRepository: $syncStateRepository,
|
||||
);
|
||||
|
||||
$accommodationRepository = $this->createStub(AccommodationRepository::class);
|
||||
$accommodationRepository->method('findAllWithCalendarCode')->willReturn($accommodations);
|
||||
self::assertSame(['DGS', 'DKI'], $synced);
|
||||
}
|
||||
|
||||
$manager = $this->createStub(ContingentSnapshotManager::class);
|
||||
if ([] !== $results) {
|
||||
$manager->method('sync')->willReturnOnConsecutiveCalls(...$results);
|
||||
public function testASingleHotelRunAcceptsATypo3OnlyCodeAndDropsNothing(): void
|
||||
{
|
||||
$manager = $this->createMock(ContingentSnapshotManager::class);
|
||||
$manager->expects(self::once())->method('sync')
|
||||
->with('DKI', self::anything(), self::anything())
|
||||
->willReturn(ContingentSyncResult::synced(false, 0, 0, 0, 0));
|
||||
|
||||
$dayRepository = $this->createMock(ContingentDayRepository::class);
|
||||
$dayRepository->expects(self::never())->method('deleteExceptHotelCodes');
|
||||
$syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
|
||||
$syncStateRepository->expects(self::never())->method('deleteExceptHotelCodes');
|
||||
|
||||
$tester = $this->runSync(
|
||||
['DGS' => 'accommodation', 'DKI' => 'typo3'],
|
||||
manager: $manager,
|
||||
dayRepository: $dayRepository,
|
||||
syncStateRepository: $syncStateRepository,
|
||||
options: ['--hotel' => 'DKI'],
|
||||
);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testASingleHotelRunIgnoresAnUnknownCode(): void
|
||||
{
|
||||
$manager = $this->createMock(ContingentSnapshotManager::class);
|
||||
$manager->expects(self::never())->method('sync');
|
||||
|
||||
$tester = $this->runSync(['DGS' => 'accommodation'], manager: $manager, options: ['--hotel' => 'NOPE']);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString('No accommodation calendarCode or TYPO3 calendar hotel matched', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, string> $hotels codes, or code => 'accommodation'|'typo3'
|
||||
* @param ContingentSyncResult[] $results
|
||||
* @param array<string, string> $options
|
||||
*/
|
||||
private function runSync(
|
||||
array $hotels,
|
||||
array $results = [],
|
||||
?ContingentSnapshotManager $manager = null,
|
||||
?ContingentDayRepository $dayRepository = null,
|
||||
?ContingentSyncStateRepository $syncStateRepository = null,
|
||||
array $options = [],
|
||||
): CommandTester {
|
||||
if (array_is_list($hotels)) {
|
||||
$hotels = array_fill_keys($hotels, 'accommodation');
|
||||
}
|
||||
|
||||
$directoryEntries = [];
|
||||
|
||||
foreach ($hotels as $code => $source) {
|
||||
$code = (string) $code;
|
||||
$directoryEntries[$code] = 'typo3' === $source
|
||||
? new ContingentHotel($code, null, (new CalendarHotel())->setCode($code)->setName($code))
|
||||
: new ContingentHotel($code, (new Accommodation())->setCalendarCode($code), null);
|
||||
}
|
||||
|
||||
$hotelDirectory = $this->createStub(ContingentHotelDirectory::class);
|
||||
$hotelDirectory->method('all')->willReturn($directoryEntries);
|
||||
|
||||
if (null === $manager) {
|
||||
$manager = $this->createStub(ContingentSnapshotManager::class);
|
||||
if ([] !== $results) {
|
||||
$manager->method('sync')->willReturnOnConsecutiveCalls(...$results);
|
||||
}
|
||||
}
|
||||
|
||||
$command = new BpnSyncContingentsCommand(
|
||||
$accommodationRepository,
|
||||
$this->createStub(ContingentSyncStateRepository::class),
|
||||
$this->createStub(ContingentDayRepository::class),
|
||||
$hotelDirectory,
|
||||
$syncStateRepository ?? $this->createStub(ContingentSyncStateRepository::class),
|
||||
$dayRepository ?? $this->createStub(ContingentDayRepository::class),
|
||||
$manager,
|
||||
new NullLogger(),
|
||||
);
|
||||
@@ -91,7 +170,7 @@ class BpnSyncContingentsCommandTest extends TestCase
|
||||
$application->addCommand($command);
|
||||
|
||||
$tester = new CommandTester($application->find('app:bpn:sync-contingents'));
|
||||
$tester->execute(['--horizon-months' => '1']);
|
||||
$tester->execute(['--horizon-months' => '1'] + $options);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user