diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index d1b5b461..bb688d1e 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -526,7 +526,35 @@ Two differences from `calendar`: - **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`. - **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly. -Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import. +- **Not limited to accommodations.** `hotelCode` may be any synced hotel code: an accommodation's calendar code, or the calendar hotel of a visible TYPO3 product (TYPO3's `/api/calendar-hotels`). Winter-only hotels without an accommodation are therefore served here, while `calendar` and `prices` still need an accommodation, since they need its prices. + +Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}` when the code is not synced, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import. + +#### `GET /api/contingents/status` — scope `api` + +One row per synced hotel code, sorted by code, for an overview of which hotel supports which calendar: + +```json +{ + "hotelCode": "DGS", + "name": "Sportclub Spinabad", + "sources": ["accommodation", "typo3"], + "accommodation": {"name": "Davos Glaris - Gruppenhaus Spinabad", "cmsCode": "DGSGRP"}, + "pricedFrom": "2027-06-01", + "pricedTo": "2027-09-30", + "syncedAt": "2026-10-29T10:15:00+01:00", + "lastError": null, + "failureCount": 0, + "available": true +} +``` + +- `sources`: where the code comes from, `accommodation` and/or `typo3`. +- `name`: the hotel's name in TYPO3's calendar hotel list, `null` for a code TYPO3 does not list. +- `accommodation`: `null` without one; `cmsCode` is the code the accommodation is known by in TYPO3, which may differ from `hotelCode`. +- `pricedFrom`/`pricedTo`: the span of the accommodation's prices, `null` without prices. `calendar` and `prices` work only for hotels that have them. +- `syncedAt`, `lastError`, `failureCount`: the last successful sync (`null` if none) and the failures since. +- `available`: whether `availability` currently serves the hotel, i.e. it has synced within the last 6 hours. `false` means that endpoint answers `502`. --- @@ -543,6 +571,8 @@ Rollout order: Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally. +**Which hotels are synced.** Every accommodation's calendar code plus every code in TYPO3's `/api/calendar-hotels` (same `APP_CMS_API_KEY` as the other CMS calls). Each run fetches TYPO3's list first and stores it in `calendar_hotel`; when TYPO3 errors or answers with an empty list, the run logs a warning and uses the stored list, so TYPO3 being down never drops its hotels. `--hotel=` accepts any code from either source. A full run deletes the snapshot of codes that are in neither source any more; they answer `400` from then on, and come back with the next run if they reappear. + ### Freshness and propagation There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side. diff --git a/migrations/Version20261029120000.php b/migrations/Version20261029120000.php new file mode 100644 index 00000000..d5e20b39 --- /dev/null +++ b/migrations/Version20261029120000.php @@ -0,0 +1,54 @@ +addSql('CREATE TABLE calendar_hotel (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_6F075DB777153098 (code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + + // Backfill from the linked accommodation. accommodation_id stays, nullable and no longer + // written, until a later migration drops it; that keeps this step reversible. + $this->addSql('ALTER TABLE contingent_day ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL'); + $this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.id = cd.accommodation_id SET cd.hotel_code = a.calendar_code'); + $this->addSql('DELETE FROM contingent_day WHERE hotel_code IS NULL OR hotel_code = \'\''); + $this->addSql('ALTER TABLE contingent_day CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL'); + $this->addSql('DROP INDEX uniq_contingent_day_accommodation_date ON contingent_day'); + $this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_hotel_code_date ON contingent_day (hotel_code, date)'); + + $this->addSql('ALTER TABLE contingent_sync_state ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL'); + $this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.id = s.accommodation_id SET s.hotel_code = a.calendar_code'); + $this->addSql('DELETE FROM contingent_sync_state WHERE hotel_code IS NULL OR hotel_code = \'\''); + $this->addSql('ALTER TABLE contingent_sync_state CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state (hotel_code)'); + } + + public function down(Schema $schema): void + { + // Rows of hotel codes without an accommodation cannot be expressed in the old schema. + // The snapshot is a cache, so the next sync of the old code rebuilds whatever is lost. + $this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.calendar_code = cd.hotel_code SET cd.accommodation_id = a.id'); + $this->addSql('DELETE cd FROM contingent_day cd LEFT JOIN accommodation a ON a.calendar_code = cd.hotel_code WHERE a.id IS NULL'); + $this->addSql('DROP INDEX uniq_contingent_day_hotel_code_date ON contingent_day'); + $this->addSql('ALTER TABLE contingent_day DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_accommodation_date ON contingent_day (accommodation_id, date)'); + + $this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.calendar_code = s.hotel_code SET s.accommodation_id = a.id'); + $this->addSql('DELETE s FROM contingent_sync_state s LEFT JOIN accommodation a ON a.calendar_code = s.hotel_code WHERE a.id IS NULL'); + $this->addSql('DROP INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state'); + $this->addSql('ALTER TABLE contingent_sync_state DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL'); + + $this->addSql('DROP TABLE calendar_hotel'); + } +} diff --git a/src/Command/BpnSyncContingentsCommand.php b/src/Command/BpnSyncContingentsCommand.php index e8c978c0..0d55f1e6 100644 --- a/src/Command/BpnSyncContingentsCommand.php +++ b/src/Command/BpnSyncContingentsCommand.php @@ -4,10 +4,9 @@ declare(strict_types=1); namespace App\Command; -use App\Entity\Groups\Accommodation; -use App\Repository\Groups\AccommodationRepository; use App\Repository\Groups\ContingentDayRepository; use App\Repository\Groups\ContingentSyncStateRepository; +use App\Service\ContingentHotelDirectory; use App\Service\ContingentSnapshotManager; use Carbon\CarbonImmutable; use Psr\Log\LoggerInterface; @@ -28,14 +27,18 @@ use Symfony\Component\Console\Style\SymfonyStyle; * * Scheduled over the full horizon throughout: every 15 minutes during peak hours, hourly * outside them. The options exist for ad-hoc runs — a narrower --horizon-months, or --hotel - * to re-sync a single accommodation while debugging. + * to re-sync a single hotel code while debugging. + * + * Syncs every code of {@see ContingentHotelDirectory}: the accommodations' calendar codes plus + * TYPO3's calendar hotels. A full run also drops the snapshot of codes that have left both + * sources, so the snapshot tables only ever describe hotels that are still synced. */ class BpnSyncContingentsCommand extends Command { use LockableTrait; public function __construct( - private readonly AccommodationRepository $accommodationRepository, + private readonly ContingentHotelDirectory $hotelDirectory, private readonly ContingentSyncStateRepository $syncStateRepository, private readonly ContingentDayRepository $dayRepository, private readonly ContingentSnapshotManager $snapshotManager, @@ -51,8 +54,8 @@ class BpnSyncContingentsCommand extends Command { $this ->addOption('horizon-months', null, InputOption::VALUE_REQUIRED, 'How many months ahead to sync (defaults to the full horizon)', '24') - ->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single calendarCode') - ->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before an accommodation is eligible (ignored with --force)', '0') + ->addOption('hotel', null, InputOption::VALUE_REQUIRED, 'Restrict the run to a single hotel code') + ->addOption('stale-after', null, InputOption::VALUE_REQUIRED, 'Minimum minutes since the last sync before a hotel is eligible (ignored with --force)', '0') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Sync even if the snapshot was refreshed recently') ; } @@ -79,10 +82,13 @@ class BpnSyncContingentsCommand extends Command $dateFrom = $today->toDateTimeImmutable(); $dateTo = $today->addMonths($horizonMonths)->toDateTimeImmutable(); - $accommodations = $this->resolveAccommodations(is_string($hotelCode) ? $hotelCode : null); + $this->hotelDirectory->refresh(); + // array_keys() turns a numeric code into an int. + $allHotelCodes = array_map('strval', array_keys($this->hotelDirectory->all())); + $hotelCodes = is_string($hotelCode) ? array_values(array_intersect($allHotelCodes, [$hotelCode])) : $allHotelCodes; - if ([] === $accommodations) { - $io->warning('No accommodation with a calendarCode matched.'); + if ([] === $hotelCodes) { + $io->warning('No accommodation calendarCode or TYPO3 calendar hotel matched.'); return Command::SUCCESS; } @@ -92,19 +98,19 @@ class BpnSyncContingentsCommand extends Command $changed = 0; $failedCodes = []; - foreach ($accommodations as $accommodation) { - if (!$force && $this->isFresh($accommodation, $staleAfterMinutes)) { + foreach ($hotelCodes as $code) { + if (!$force && $this->isFresh($code, $staleAfterMinutes)) { ++$skipped; continue; } - $result = $this->snapshotManager->sync($accommodation, $dateFrom, $dateTo); + $result = $this->snapshotManager->sync($code, $dateFrom, $dateTo); ++$processed; if (!$result->successful) { - $failedCodes[] = (string) $accommodation->getCalendarCode(); - $io->warning(sprintf('%s: %s', (string) $accommodation->getCalendarCode(), (string) $result->error)); + $failedCodes[] = $code; + $io->warning(sprintf('%s: %s', $code, (string) $result->error)); continue; } @@ -115,7 +121,7 @@ class BpnSyncContingentsCommand extends Command $io->writeln(sprintf( '%s: %s (+%d ~%d -%d, %d beyond the previous horizon)', - (string) $accommodation->getCalendarCode(), + $code, $result->changed ? 'changed' : 'unchanged', $result->added, $result->updated, @@ -149,6 +155,21 @@ class BpnSyncContingentsCommand extends Command $deleted = $this->dayRepository->deleteBefore($today->subDay()->toDateTimeImmutable()); $io->note(sprintf('Deleted %d outdated snapshot days.', $deleted)); + // Only a full run knows the whole set. A code that left both sources is not served any + // more (the availability endpoint answers 400 for it), so its snapshot is dead weight; + // should it come back, the next run rebuilds it. + if (!is_string($hotelCode)) { + $orphanedDays = $this->dayRepository->deleteExceptHotelCodes($allHotelCodes); + $orphanedStates = $this->syncStateRepository->deleteExceptHotelCodes($allHotelCodes); + + if ($orphanedStates > 0) { + $this->logger->info('Dropped the contingent snapshot of hotel codes that are no longer synced', [ + 'hotels' => $orphanedStates, + 'days' => $orphanedDays, + ]); + } + } + // Only a *total* failure is worth failing the task over: that means upstream is down or // the API key is rejected, and the scheduler's failure mail is genuinely actionable. // A single hotel breaking must stay quiet — this task runs every 15 minutes during the @@ -158,27 +179,13 @@ class BpnSyncContingentsCommand extends Command return $processed > 0 && $failed === $processed ? Command::FAILURE : Command::SUCCESS; } - /** - * @return Accommodation[] - */ - private function resolveAccommodations(?string $hotelCode): array - { - if (null === $hotelCode) { - return $this->accommodationRepository->findAllWithCalendarCode(); - } - - $accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode); - - return null !== $accommodation ? [$accommodation] : []; - } - - private function isFresh(Accommodation $accommodation, int $staleAfterMinutes): bool + private function isFresh(string $hotelCode, int $staleAfterMinutes): bool { if (0 === $staleAfterMinutes) { return false; } - $syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt(); + $syncedAt = $this->syncStateRepository->findOneByHotelCode($hotelCode)?->getSyncedAt(); if (null === $syncedAt) { return false; diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index fda2e05d..0486adb1 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -11,7 +11,9 @@ use App\Model\ContingentCalendarQuery; use App\Model\ContingentPricesQuery; use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationRepository; +use App\Repository\Groups\ContingentSyncStateRepository; use App\Service\AccommodationPriceCoverage; +use App\Service\ContingentHotelDirectory; use App\Service\ContingentSnapshotReader; use App\Service\PriceTimelineBuilder; use Carbon\CarbonImmutable; @@ -33,6 +35,8 @@ class ContingentController extends AbstractController private readonly ContingentSnapshotReader $snapshotReader, private readonly PriceTimelineBuilder $priceTimelineBuilder, private readonly AccommodationPriceCoverage $priceCoverage, + private readonly ContingentHotelDirectory $hotelDirectory, + private readonly ContingentSyncStateRepository $syncStateRepository, private readonly LoggerInterface $logger, ) { } @@ -107,7 +111,7 @@ class ContingentController extends AbstractController // An empty or stale snapshot must not be served as though every day were blocked: that // is a plausible-looking 200 nobody can distinguish from real data. Fail the way this // endpoint always failed instead, so existing consumers need no change. - $statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo); + $statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo); if (null === $statuses) { return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); @@ -137,15 +141,16 @@ class ContingentController extends AbstractController * The snapshot as it is, for the iCal feed: no price rule, and days the snapshot lacks are * left out rather than blocked, so a portal shows them as free like it did with the feed * that read bpn-connect directly. + * + * Serves every synced hotel code, including TYPO3's calendar hotels that have no + * accommodation (winter-only hotels). */ #[Route(path: '/contingents/availability', name: 'api_contingents_availability', methods: ['GET'])] public function availability( #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] ContingentCalendarQuery $query, ): JsonResponse { - $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); - - if (null === $accommodation) { + if (null === $this->hotelDirectory->find($query->hotelCode)) { $this->logger->error('Hotel not found for hotelcode '.$query->hotelCode); return $this->json(['error' => 'Hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); @@ -157,7 +162,7 @@ class ContingentController extends AbstractController // Same reasoning as calendar(): a missing or stale snapshot must not pass for real data. // Here it matters even more, since an empty feed would show every day as free. - $statuses = $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo); + $statuses = $this->snapshotReader->statusesFor($query->hotelCode, $dateFrom, $dateTo); if (null === $statuses) { return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); @@ -174,6 +179,44 @@ class ContingentController extends AbstractController return $this->json($data); } + /** + * One row per synced hotel code, for the overview of which hotel supports which calendar. + * + * `available` is whether `availability` currently serves the hotel; calendar and price table + * additionally need an accommodation with prices, which `pricedFrom`/`pricedTo` show. + */ + #[Route(path: '/contingents/status', name: 'api_contingents_status', methods: ['GET'])] + public function status(): JsonResponse + { + $states = $this->syncStateRepository->findAllIndexedByHotelCode(); + + $data = []; + + foreach ($this->hotelDirectory->all() as $hotel) { + $state = $states[$hotel->code] ?? null; + $accommodation = $hotel->accommodation; + $bounds = null !== $accommodation ? $this->priceRepository->findPricedDateBoundsByHotelCode($hotel->code) : null; + + $data[] = [ + 'hotelCode' => $hotel->code, + 'name' => $hotel->calendarHotel?->getName(), + 'sources' => array_map(static fn ($source) => $source->value, $hotel->sources()), + 'accommodation' => null !== $accommodation ? [ + 'name' => $accommodation->getName(), + 'cmsCode' => $accommodation->getEffectiveCmsCode(), + ] : null, + 'pricedFrom' => null !== $bounds ? $bounds['from']->format('Y-m-d') : null, + 'pricedTo' => null !== $bounds ? $bounds['to']->format('Y-m-d') : null, + 'syncedAt' => $state?->getSyncedAt()?->format(\DateTimeInterface::ATOM), + 'lastError' => $state?->getLastError(), + 'failureCount' => $state?->getFailureCount() ?? 0, + 'available' => $this->snapshotReader->isCurrent($state), + ]; + } + + return $this->json($data); + } + /** * @param AccommodationPrice[] $prices * @param array $covered diff --git a/src/Controller/Groups/Booking/Step1Controller.php b/src/Controller/Groups/Booking/Step1Controller.php index 171185d7..eae40dcf 100644 --- a/src/Controller/Groups/Booking/Step1Controller.php +++ b/src/Controller/Groups/Booking/Step1Controller.php @@ -240,7 +240,7 @@ class Step1Controller extends AbstractController \DateTimeImmutable $dateTo, ): array { $contingentStatus = null !== $accommodation - ? $this->snapshotReader->statusesFor($accommodation, $dateFrom, $dateTo) ?? [] + ? $this->snapshotReader->statusesFor($hotelCode, $dateFrom, $dateTo) ?? [] : []; $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); diff --git a/src/Entity/Groups/CalendarHotel.php b/src/Entity/Groups/CalendarHotel.php new file mode 100644 index 00000000..99309d90 --- /dev/null +++ b/src/Entity/Groups/CalendarHotel.php @@ -0,0 +1,60 @@ +id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): self + { + $this->code = $code; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/src/Entity/Groups/ContingentDay.php b/src/Entity/Groups/ContingentDay.php index a7726d4e..c404d4e5 100644 --- a/src/Entity/Groups/ContingentDay.php +++ b/src/Entity/Groups/ContingentDay.php @@ -12,11 +12,14 @@ use Doctrine\ORM\Mapping as ORM; /** * Local snapshot of a single day of contingent availability as reported by bpn-connect. * + * Keyed by hotel code, not by accommodation: the snapshot also covers TYPO3's calendar hotels, + * which have no `Accommodation`. + * * Written exclusively by the scheduled sync, never by a user, hence no Blameable/Timestampable. */ #[ORM\Entity(repositoryClass: ContingentDayRepository::class)] #[ORM\Table(name: 'contingent_day')] -#[ORM\UniqueConstraint(name: 'uniq_contingent_day_accommodation_date', columns: ['accommodation_id', 'date'])] +#[ORM\UniqueConstraint(name: 'uniq_contingent_day_hotel_code_date', columns: ['hotel_code', 'date'])] class ContingentDay { #[ORM\Id] @@ -24,8 +27,15 @@ class ContingentDay #[ORM\Column] private ?int $id = null; + #[ORM\Column(length: 16)] + private ?string $hotelCode = null; + + /** + * Superseded by $hotelCode and no longer written. Only rows from before the switch still + * carry it; it stays mapped until the column is dropped, so the switch can be rolled back. + */ #[ORM\ManyToOne] - #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')] private ?Accommodation $accommodation = null; #[ORM\Column(type: Types::DATE_IMMUTABLE)] @@ -39,6 +49,18 @@ class ContingentDay return $this->id; } + public function getHotelCode(): ?string + { + return $this->hotelCode; + } + + public function setHotelCode(string $hotelCode): self + { + $this->hotelCode = $hotelCode; + + return $this; + } + public function getAccommodation(): ?Accommodation { return $this->accommodation; diff --git a/src/Entity/Groups/ContingentSyncState.php b/src/Entity/Groups/ContingentSyncState.php index 97f48129..a7879894 100644 --- a/src/Entity/Groups/ContingentSyncState.php +++ b/src/Entity/Groups/ContingentSyncState.php @@ -9,7 +9,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; /** - * Bookkeeping for the scheduled contingent sync of a single accommodation. + * Bookkeeping for the scheduled contingent sync of a single hotel code. * * `changedAt` records when the sync last saw a day appear, vanish or switch status within the * horizon it had already reached; `horizonTo` is how far that reach extends. @@ -23,8 +23,14 @@ class ContingentSyncState #[ORM\Column] private ?int $id = null; + #[ORM\Column(length: 16, unique: true)] + private ?string $hotelCode = null; + + /** + * Superseded by $hotelCode, see {@see ContingentDay::$accommodation}. + */ #[ORM\OneToOne] - #[ORM\JoinColumn(nullable: false, unique: true, onDelete: 'CASCADE')] + #[ORM\JoinColumn(nullable: true, unique: true, onDelete: 'CASCADE')] private ?Accommodation $accommodation = null; /** @@ -48,9 +54,9 @@ class ContingentSyncState #[ORM\Column] private int $failureCount = 0; - public function __construct(?Accommodation $accommodation = null) + public function __construct(?string $hotelCode = null) { - $this->accommodation = $accommodation; + $this->hotelCode = $hotelCode; } public function getId(): ?int @@ -58,6 +64,11 @@ class ContingentSyncState return $this->id; } + public function getHotelCode(): ?string + { + return $this->hotelCode; + } + public function getAccommodation(): ?Accommodation { return $this->accommodation; diff --git a/src/Enum/ContingentHotelSource.php b/src/Enum/ContingentHotelSource.php new file mode 100644 index 00000000..414fb1ba --- /dev/null +++ b/src/Enum/ContingentHotelSource.php @@ -0,0 +1,17 @@ + + */ + public function sources(): array + { + $sources = []; + + if (null !== $this->accommodation) { + $sources[] = ContingentHotelSource::Accommodation; + } + + if (null !== $this->calendarHotel) { + $sources[] = ContingentHotelSource::Typo3; + } + + return $sources; + } +} diff --git a/src/Repository/Groups/CalendarHotelRepository.php b/src/Repository/Groups/CalendarHotelRepository.php new file mode 100644 index 00000000..088742ae --- /dev/null +++ b/src/Repository/Groups/CalendarHotelRepository.php @@ -0,0 +1,34 @@ + + */ +class CalendarHotelRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CalendarHotel::class); + } + + /** + * @return array + */ + public function findAllIndexedByCode(): array + { + $indexed = []; + + foreach ($this->findAll() as $hotel) { + $indexed[(string) $hotel->getCode()] = $hotel; + } + + return $indexed; + } +} diff --git a/src/Repository/Groups/ContingentDayRepository.php b/src/Repository/Groups/ContingentDayRepository.php index f49e912a..4f8e16c0 100644 --- a/src/Repository/Groups/ContingentDayRepository.php +++ b/src/Repository/Groups/ContingentDayRepository.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Repository\Groups; -use App\Entity\Groups\Accommodation; use App\Entity\Groups\ContingentDay; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -20,9 +19,8 @@ class ContingentDayRepository extends ServiceEntityRepository } /** - * Returns the snapshot days within [dateFrom, dateTo] for the accommodation - * identified by the given hotel code, keyed by Y-m-d. A null dateTo means - * everything from dateFrom to the end of the snapshot. + * Returns the snapshot days of a hotel code within [dateFrom, dateTo], keyed by Y-m-d. + * A null dateTo means everything from dateFrom to the end of the snapshot. * * @return array */ @@ -32,8 +30,7 @@ class ContingentDayRepository extends ServiceEntityRepository ?\DateTimeImmutable $dateTo, ): array { $qb = $this->createQueryBuilder('cd') - ->join('cd.accommodation', 'a') - ->where('a.calendarCode = :hotelCode') + ->where('cd.hotelCode = :hotelCode') ->andWhere('cd.date >= :dateFrom') ->setParameter('hotelCode', $hotelCode) ->setParameter('dateFrom', $dateFrom) @@ -50,31 +47,6 @@ class ContingentDayRepository extends ServiceEntityRepository return $this->indexByDate($days); } - /** - * Same as above but for a known accommodation; used by the sync to diff against. - * - * @return array - */ - public function findByAccommodationAndDateRange( - Accommodation $accommodation, - \DateTimeImmutable $dateFrom, - \DateTimeImmutable $dateTo, - ): array { - /** @var ContingentDay[] $days */ - $days = $this->createQueryBuilder('cd') - ->where('cd.accommodation = :accommodation') - ->andWhere('cd.date >= :dateFrom') - ->andWhere('cd.date <= :dateTo') - ->setParameter('accommodation', $accommodation) - ->setParameter('dateFrom', $dateFrom) - ->setParameter('dateTo', $dateTo) - ->orderBy('cd.date', 'ASC') - ->getQuery() - ->getResult(); - - return $this->indexByDate($days); - } - /** * Retention: drops snapshot days that are in the past and can no longer be requested. */ @@ -88,6 +60,21 @@ class ContingentDayRepository extends ServiceEntityRepository ->execute(); } + /** + * Drops the days of every hotel code that is no longer synced. + * + * @param non-empty-list $hotelCodes the codes that are still synced + */ + public function deleteExceptHotelCodes(array $hotelCodes): int + { + return (int) $this->createQueryBuilder('cd') + ->delete() + ->where('cd.hotelCode NOT IN (:hotelCodes)') + ->setParameter('hotelCodes', $hotelCodes) + ->getQuery() + ->execute(); + } + /** * @param ContingentDay[] $days * diff --git a/src/Repository/Groups/ContingentSyncStateRepository.php b/src/Repository/Groups/ContingentSyncStateRepository.php index 6a771041..ae3289c2 100644 --- a/src/Repository/Groups/ContingentSyncStateRepository.php +++ b/src/Repository/Groups/ContingentSyncStateRepository.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Repository\Groups; -use App\Entity\Groups\Accommodation; use App\Entity\Groups\ContingentSyncState; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -19,30 +18,37 @@ class ContingentSyncStateRepository extends ServiceEntityRepository parent::__construct($registry, ContingentSyncState::class); } - public function findOneByAccommodation(Accommodation $accommodation): ?ContingentSyncState + public function findOneByHotelCode(string $hotelCode): ?ContingentSyncState { - return $this->findOneBy(['accommodation' => $accommodation]); + return $this->findOneBy(['hotelCode' => $hotelCode]); } /** - * Returns every sync state keyed by accommodation id, for the revisions endpoint. - * - * @return array + * @return array */ - public function findAllIndexedByAccommodationId(): array + public function findAllIndexedByHotelCode(): array { $indexed = []; foreach ($this->findAll() as $state) { - $accommodation = $state->getAccommodation(); - - if (null === $accommodation || null === $accommodation->getId()) { - continue; - } - - $indexed[$accommodation->getId()] = $state; + $indexed[(string) $state->getHotelCode()] = $state; } return $indexed; } + + /** + * Drops the sync state of every hotel code that is no longer synced. + * + * @param non-empty-list $hotelCodes the codes that are still synced + */ + public function deleteExceptHotelCodes(array $hotelCodes): int + { + return (int) $this->createQueryBuilder('s') + ->delete() + ->where('s.hotelCode NOT IN (:hotelCodes)') + ->setParameter('hotelCodes', $hotelCodes) + ->getQuery() + ->execute(); + } } diff --git a/src/Service/CmsDataProvider.php b/src/Service/CmsDataProvider.php index c7e66845..39a07e57 100644 --- a/src/Service/CmsDataProvider.php +++ b/src/Service/CmsDataProvider.php @@ -2,6 +2,7 @@ namespace App\Service; +use App\Model\CmsCalendarHotel; use App\Model\CmsHotelData; use App\Model\CmsRegionData; use Symfony\Contracts\Cache\CacheInterface; @@ -169,6 +170,40 @@ class CmsDataProvider return is_numeric($value) ? (float) $value : null; } + /** + * The calendar hotels of TYPO3's visible products, or null when TYPO3 did not answer with a + * usable list. Uncached: the only caller keeps its own copy of the last good answer. + * + * @return list|null + */ + public function fetchCalendarHotels(): ?array + { + try { + $data = $this->httpClient->request('GET', 'api/calendar-hotels')->toArray(); + } catch (ExceptionInterface) { + return null; + } + + if (!array_is_list($data)) { + return null; + } + + $hotels = []; + + foreach ($data as $entry) { + $code = $entry['code'] ?? null; + + if (!is_string($code) || '' === $code) { + continue; + } + + $name = $entry['name'] ?? null; + $hotels[] = new CmsCalendarHotel($code, is_string($name) && '' !== $name ? $name : $code); + } + + return $hotels; + } + /** @return array */ public function getProductDetails(string $productCode, ?string $hotelCode = null): array { diff --git a/src/Service/ContingentHotelDirectory.php b/src/Service/ContingentHotelDirectory.php new file mode 100644 index 00000000..d119939d --- /dev/null +++ b/src/Service/ContingentHotelDirectory.php @@ -0,0 +1,124 @@ +cmsDataProvider->fetchCalendarHotels() ?? [] as $hotel) { + if (1 !== preg_match(self::CODE_PATTERN, $hotel->code)) { + $this->logger->warning('Ignoring TYPO3 calendar hotel with an unusable code', ['hotelCode' => $hotel->code]); + + continue; + } + + $fetched[$hotel->code] = $hotel; + } + + if ([] === $fetched) { + $this->logger->warning('TYPO3 calendar hotels unavailable, keeping the stored list'); + + return false; + } + + $stored = $this->calendarHotelRepository->findAllIndexedByCode(); + + foreach ($fetched as $code => $hotel) { + $calendarHotel = $stored[$code] ?? null; + + if (null === $calendarHotel) { + $this->entityManager->persist((new CalendarHotel())->setCode($code)->setName($hotel->name)); + } elseif ($calendarHotel->getName() !== $hotel->name) { + $calendarHotel->setName($hotel->name); + } + } + + foreach (array_diff_key($stored, $fetched) as $calendarHotel) { + $this->entityManager->remove($calendarHotel); + } + + $this->entityManager->flush(); + + return true; + } + + /** + * @return array keyed and sorted by hotel code + */ + public function all(): array + { + $accommodations = []; + + foreach ($this->accommodationRepository->findAllWithCalendarCode() as $accommodation) { + $accommodations[(string) $accommodation->getCalendarCode()] = $accommodation; + } + + $calendarHotels = $this->calendarHotelRepository->findAllIndexedByCode(); + + $hotels = []; + + foreach (array_keys($accommodations + $calendarHotels) as $code) { + $code = (string) $code; + $hotels[$code] = new ContingentHotel($code, $accommodations[$code] ?? null, $calendarHotels[$code] ?? null); + } + + ksort($hotels, \SORT_STRING); + + return $hotels; + } + + public function find(string $hotelCode): ?ContingentHotel + { + if ('' === $hotelCode) { + return null; + } + + $accommodation = $this->accommodationRepository->findOneByCalendarCode($hotelCode); + $calendarHotel = $this->calendarHotelRepository->findOneBy(['code' => $hotelCode]); + + if (null === $accommodation && null === $calendarHotel) { + return null; + } + + return new ContingentHotel($hotelCode, $accommodation, $calendarHotel); + } +} diff --git a/src/Service/ContingentSnapshotManager.php b/src/Service/ContingentSnapshotManager.php index 1905ba89..090c0abf 100644 --- a/src/Service/ContingentSnapshotManager.php +++ b/src/Service/ContingentSnapshotManager.php @@ -6,7 +6,6 @@ namespace App\Service; use App\BpnConnect\ContingentsClient; use App\BpnConnect\Exception\BpnConnectException; -use App\Entity\Groups\Accommodation; use App\Entity\Groups\ContingentDay; use App\Entity\Groups\ContingentSyncState; use App\Model\ContingentSyncResult; @@ -34,24 +33,22 @@ class ContingentSnapshotManager } /** - * Refreshes [dateFrom, dateTo] for a single accommodation. + * Refreshes [dateFrom, dateTo] for a single hotel code. * * Days are fetched, diffed against what is stored and then persisted; the snapshot outside * the requested window is left untouched, so a narrow ad-hoc run cannot truncate what a * full-horizon run already stored. */ public function sync( - Accommodation $accommodation, + string $hotelCode, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, ): ContingentSyncResult { - $hotelCode = $accommodation->getCalendarCode(); - - if (null === $hotelCode || '' === $hotelCode) { - return ContingentSyncResult::failed('Accommodation has no calendarCode.'); + if ('' === $hotelCode) { + return ContingentSyncResult::failed('No hotel code given.'); } - $state = $this->syncStateRepository->findOneByAccommodation($accommodation) ?? new ContingentSyncState($accommodation); + $state = $this->syncStateRepository->findOneByHotelCode($hotelCode) ?? new ContingentSyncState($hotelCode); // Read before recordSuccess() moves it: days beyond the horizon the previous run reached // are the window growing, not the contingent situation changing. @@ -67,7 +64,7 @@ class ContingentSnapshotManager return $this->recordFailure($state, $hotelCode, $e->getMessage()); } - $existing = $this->dayRepository->findByAccommodationAndDateRange($accommodation, $dateFrom, $dateTo); + $existing = $this->dayRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); // An empty upstream response would otherwise wipe the window and render the whole // period as BLOCKED. Treat it as an upstream problem and keep the last good snapshot. @@ -103,7 +100,7 @@ class ContingentSnapshotManager if (null === $day) { $day = (new ContingentDay()) - ->setAccommodation($accommodation) + ->setHotelCode($hotelCode) ->setDate($date); $this->entityManager->persist($day); diff --git a/src/Service/ContingentSnapshotReader.php b/src/Service/ContingentSnapshotReader.php index 2e939ab0..b63df050 100644 --- a/src/Service/ContingentSnapshotReader.php +++ b/src/Service/ContingentSnapshotReader.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Service; use App\BpnConnect\Model\ContingentStatus; -use App\Entity\Groups\Accommodation; +use App\Entity\Groups\ContingentSyncState; use App\Repository\Groups\ContingentDayRepository; use App\Repository\Groups\ContingentSyncStateRepository; use Carbon\CarbonImmutable; @@ -46,17 +46,15 @@ class ContingentSnapshotReader * @return array|null */ public function statusesFor( - Accommodation $accommodation, + string $hotelCode, \DateTimeImmutable $dateFrom, ?\DateTimeImmutable $dateTo, ): ?array { - $hotelCode = $accommodation->getCalendarCode(); - - if (null === $hotelCode || '' === $hotelCode) { + if ('' === $hotelCode) { return null; } - if (!$this->isUsable($accommodation, $hotelCode)) { + if (!$this->isUsable($hotelCode)) { return null; } @@ -69,25 +67,36 @@ class ContingentSnapshotReader return $statuses; } - private function isUsable(Accommodation $accommodation, string $hotelCode): bool + /** + * Whether a hotel with this sync state gets statuses from {@see self::statusesFor()}, without + * the logging: for overviews that ask about every hotel at once. + */ + public function isCurrent(?ContingentSyncState $state): bool { - $syncedAt = $this->syncStateRepository->findOneByAccommodation($accommodation)?->getSyncedAt(); + $syncedAt = $state?->getSyncedAt(); + + return null !== $syncedAt && $syncedAt >= CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE); + } + + private function isUsable(string $hotelCode): bool + { + $state = $this->syncStateRepository->findOneByHotelCode($hotelCode); + + if ($this->isCurrent($state)) { + return true; + } + + $syncedAt = $state?->getSyncedAt(); if (null === $syncedAt) { $this->logger->error('No contingent snapshot has been synced yet', ['hotelCode' => $hotelCode]); - - return false; - } - - if ($syncedAt < CarbonImmutable::now()->modify(self::MAX_SNAPSHOT_AGE)) { + } else { $this->logger->error('Contingent snapshot is stale', [ 'hotelCode' => $hotelCode, 'syncedAt' => $syncedAt->format(\DateTimeInterface::ATOM), ]); - - return false; } - return true; + return false; } } diff --git a/tests/Command/BpnSyncContingentsCommandTest.php b/tests/Command/BpnSyncContingentsCommandTest.php index 99c858da..35ea2341 100644 --- a/tests/Command/BpnSyncContingentsCommandTest.php +++ b/tests/Command/BpnSyncContingentsCommandTest.php @@ -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 $hotels codes, or code => 'accommodation'|'typo3' + * @param ContingentSyncResult[] $results + * @param array $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. diff --git a/tests/Controller/Api/ContingentControllerTest.php b/tests/Controller/Api/ContingentControllerTest.php index 20d8dd3f..1f2c1f5d 100644 --- a/tests/Controller/Api/ContingentControllerTest.php +++ b/tests/Controller/Api/ContingentControllerTest.php @@ -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), ); } diff --git a/tests/Service/CmsDataProviderTest.php b/tests/Service/CmsDataProviderTest.php index aa7dc77d..83f5098c 100644 --- a/tests/Service/CmsDataProviderTest.php +++ b/tests/Service/CmsDataProviderTest.php @@ -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()); + } } diff --git a/tests/Service/ContingentHotelDirectoryTest.php b/tests/Service/ContingentHotelDirectoryTest.php new file mode 100644 index 00000000..90b28ea9 --- /dev/null +++ b/tests/Service/ContingentHotelDirectoryTest.php @@ -0,0 +1,148 @@ +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|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), + ); + } +} diff --git a/tests/Service/ContingentSnapshotManagerTest.php b/tests/Service/ContingentSnapshotManagerTest.php index e9cdfd52..24f1b995 100644 --- a/tests/Service/ContingentSnapshotManagerTest.php +++ b/tests/Service/ContingentSnapshotManagerTest.php @@ -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()) diff --git a/tests/Service/ContingentSnapshotReaderTest.php b/tests/Service/ContingentSnapshotReaderTest.php index fece6e7f..5014fc12 100644 --- a/tests/Service/ContingentSnapshotReaderTest.php +++ b/tests/Service/ContingentSnapshotReaderTest.php @@ -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|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'), );