feat: sync contingents for TYPO3 calendar hotels

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

No files matched your search

+31 -1
View File
@@ -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=<code>` 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.
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261029120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Keys the contingent snapshot by hotel code instead of accommodation, and adds calendar_hotel for TYPO3\'s calendar hotel list.';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+38 -31
View File
@@ -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(
'<info>%s</info>: %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;
+48 -5
View File
@@ -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<string, true> $covered
@@ -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);
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Entity\Groups;
use App\Repository\Groups\CalendarHotelRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* Local copy of the last calendar hotel list TYPO3 answered successfully.
*
* Kept so the contingent sync still covers TYPO3's hotels while TYPO3 is unreachable. Written
* only by {@see \App\Service\ContingentHotelDirectory::refresh()}, never by hand: the list is
* TYPO3's, this is not a third place to maintain hotel codes in.
*/
#[ORM\Entity(repositoryClass: CalendarHotelRepository::class)]
#[ORM\Table(name: 'calendar_hotel')]
class CalendarHotel
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 16, unique: true)]
private ?string $code = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
public function getId(): ?int
{
return $this->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;
}
}
+24 -2
View File
@@ -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;
+15 -4
View File
@@ -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;
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Where a hotel code in the contingent snapshot comes from. A code can have both.
*/
enum ContingentHotelSource: string
{
/** The calendarCode of a group house (`Accommodation`). */
case Accommodation = 'accommodation';
/** The calendar hotel of a visible TYPO3 product, from TYPO3's `/api/calendar-hotels`. */
case Typo3 = 'typo3';
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* One entry of TYPO3's `/api/calendar-hotels`: the calendar hotel of a visible product.
*
* TYPO3 also lists the products per hotel; only the code and the name are kept here.
*/
final readonly class CmsCalendarHotel
{
public function __construct(
public string $code,
public string $name,
) {
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Model;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\CalendarHotel;
use App\Enum\ContingentHotelSource;
/**
* A hotel code the contingent snapshot is kept for, with whatever it is known as locally.
*/
final readonly class ContingentHotel
{
public function __construct(
public string $code,
public ?Accommodation $accommodation,
public ?CalendarHotel $calendarHotel,
) {
}
/**
* @return list<ContingentHotelSource>
*/
public function sources(): array
{
$sources = [];
if (null !== $this->accommodation) {
$sources[] = ContingentHotelSource::Accommodation;
}
if (null !== $this->calendarHotel) {
$sources[] = ContingentHotelSource::Typo3;
}
return $sources;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\CalendarHotel;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CalendarHotel>
*/
class CalendarHotelRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CalendarHotel::class);
}
/**
* @return array<string, CalendarHotel>
*/
public function findAllIndexedByCode(): array
{
$indexed = [];
foreach ($this->findAll() as $hotel) {
$indexed[(string) $hotel->getCode()] = $hotel;
}
return $indexed;
}
}
@@ -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<string, ContingentDay>
*/
@@ -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<string, ContingentDay>
*/
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<string> $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
*
@@ -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<int, ContingentSyncState>
* @return array<string, ContingentSyncState>
*/
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<string> $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();
}
}
+35
View File
@@ -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<CmsCalendarHotel>|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<string, mixed> */
public function getProductDetails(string $productCode, ?string $hotelCode = null): array
{
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Groups\CalendarHotel;
use App\Model\ContingentHotel;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\CalendarHotelRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* The hotel codes the contingent snapshot is kept for: every accommodation's calendarCode plus
* the calendar hotels of TYPO3's visible products.
*
* Neither source is maintained here. Accommodations are the Groups admin's, the TYPO3 list is
* TYPO3's; `calendar_hotel` only holds the last list TYPO3 answered with, so an unreachable
* TYPO3 does not make its hotels drop out of the sync.
*/
class ContingentHotelDirectory
{
// Same rule as the hotelCode query parameter of the contingent API: a code nobody can ask
// for is not worth syncing, and it would not fit the column either.
private const string CODE_PATTERN = '/^[A-Za-z0-9_-]{1,16}$/';
public function __construct(
private readonly CmsDataProvider $cmsDataProvider,
private readonly AccommodationRepository $accommodationRepository,
private readonly CalendarHotelRepository $calendarHotelRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
/**
* Replaces the stored TYPO3 list with the one TYPO3 serves now.
*
* An error or an empty list keeps the stored one: TYPO3 without a single calendar hotel is
* a fault on its side, not every winter hotel being withdrawn at once.
*/
public function refresh(): bool
{
$fetched = [];
foreach ($this->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<string, ContingentHotel> 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);
}
}
+7 -10
View File
@@ -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);
+25 -16
View File
@@ -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<string, ContingentStatus>|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;
}
}
+98 -19
View File
@@ -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),
);
}
+36
View File
@@ -79,4 +79,40 @@ class CmsDataProviderTest extends TestCase
self::assertNull($provider->getHotelDetails('UNKNOWN'));
}
public function testFetchCalendarHotelsKeepsCodeAndName(): void
{
$client = new MockHttpClient(function (string $method, string $url) {
self::assertSame('https://cms.test/api/calendar-hotels', $url);
return new MockResponse(json_encode([
['code' => 'ASCGR', 'name' => 'Ahrntal - Gruppenhaus Christiler', 'products' => [['uid' => 710, 'name' => 'Gruppenhaus Christiler', 'season' => 'w']]],
['code' => 'DKI', 'name' => '', 'products' => []],
['code' => '', 'name' => 'No code'],
['name' => 'Missing code'],
], JSON_THROW_ON_ERROR));
}, 'https://cms.test/');
$hotels = (new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels();
self::assertNotNull($hotels);
self::assertSame([['ASCGR', 'Ahrntal - Gruppenhaus Christiler'], ['DKI', 'DKI']], array_map(
static fn ($hotel) => [$hotel->code, $hotel->name],
$hotels,
));
}
public function testFetchCalendarHotelsIsNullOnAnErrorResponse(): void
{
$client = new MockHttpClient(new MockResponse('Forbidden', ['http_code' => 403]), 'https://cms.test/');
self::assertNull((new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels());
}
public function testFetchCalendarHotelsIsNullWhenTheBodyIsNotAList(): void
{
$client = new MockHttpClient(new MockResponse('{"success":false}'), 'https://cms.test/');
self::assertNull((new CmsDataProvider($client, new ArrayAdapter()))->fetchCalendarHotels());
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\CalendarHotel;
use App\Enum\ContingentHotelSource;
use App\Model\CmsCalendarHotel;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\CalendarHotelRepository;
use App\Service\CmsDataProvider;
use App\Service\ContingentHotelDirectory;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ContingentHotelDirectoryTest extends TestCase
{
private CmsDataProvider&Stub $cmsDataProvider;
private AccommodationRepository&Stub $accommodationRepository;
private CalendarHotelRepository&Stub $calendarHotelRepository;
private EntityManagerInterface&Stub $entityManager;
protected function setUp(): void
{
$this->cmsDataProvider = $this->createStub(CmsDataProvider::class);
$this->accommodationRepository = $this->createStub(AccommodationRepository::class);
$this->calendarHotelRepository = $this->createStub(CalendarHotelRepository::class);
$this->entityManager = $this->createStub(EntityManagerInterface::class);
}
public function testRefreshAddsRenamesAndRemovesStoredHotels(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$renamed = (new CalendarHotel())->setCode('DKI')->setName('Schwendi');
$dropped = (new CalendarHotel())->setCode('ASB')->setName('Sportclub Bader');
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn(['DKI' => $renamed, 'ASB' => $dropped]);
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn([
new CmsCalendarHotel('DKI', 'Sportclub Schwendi'),
new CmsCalendarHotel('DWW', 'Sportclub Weissfluh'),
]);
$this->entityManager->expects(self::once())->method('persist')
->with(self::callback(static fn (CalendarHotel $hotel) => 'DWW' === $hotel->getCode() && 'Sportclub Weissfluh' === $hotel->getName()));
$this->entityManager->expects(self::once())->method('remove')->with($dropped);
$this->entityManager->expects(self::once())->method('flush');
self::assertTrue($this->directory()->refresh());
self::assertSame('Sportclub Schwendi', $renamed->getName());
}
public function testRefreshKeepsTheStoredListWhenTypo3IsUnavailable(): void
{
$this->assertKeepsStoredList(null);
}
public function testRefreshKeepsTheStoredListWhenTypo3ReturnsNoHotels(): void
{
$this->assertKeepsStoredList([]);
}
public function testRefreshIgnoresCodesTheApiCouldNotBeAskedFor(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([]);
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn([
new CmsCalendarHotel('DKI', 'Sportclub Schwendi'),
new CmsCalendarHotel('ABCDEFGHIJKLMNOPQ', 'Too long'),
new CmsCalendarHotel('A B', 'Space'),
]);
$this->entityManager->expects(self::once())->method('persist')
->with(self::callback(static fn (CalendarHotel $hotel) => 'DKI' === $hotel->getCode()));
$this->directory()->refresh();
}
public function testAllIsTheUnionOfAccommodationsAndTypo3Hotels(): void
{
$this->accommodationRepository->method('findAllWithCalendarCode')->willReturn([
(new Accommodation())->setCalendarCode('DGS'),
(new Accommodation())->setCalendarCode('SERASCSO'),
]);
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([
'ASCGR' => (new CalendarHotel())->setCode('ASCGR')->setName('Christiler'),
'DGS' => (new CalendarHotel())->setCode('DGS')->setName('Spinabad'),
]);
$hotels = $this->directory()->all();
self::assertSame(['ASCGR', 'DGS', 'SERASCSO'], array_keys($hotels));
self::assertSame([ContingentHotelSource::Typo3], $hotels['ASCGR']->sources());
self::assertSame([ContingentHotelSource::Accommodation, ContingentHotelSource::Typo3], $hotels['DGS']->sources());
self::assertSame([ContingentHotelSource::Accommodation], $hotels['SERASCSO']->sources());
}
public function testFindKnowsATypo3OnlyHotel(): void
{
$this->accommodationRepository->method('findOneByCalendarCode')->willReturn(null);
$this->calendarHotelRepository->method('findOneBy')->willReturn((new CalendarHotel())->setCode('DKI')->setName('Schwendi'));
self::assertSame('DKI', $this->directory()->find('DKI')?->code);
}
public function testFindReturnsNullForAnUnknownCode(): void
{
$this->accommodationRepository->method('findOneByCalendarCode')->willReturn(null);
$this->calendarHotelRepository->method('findOneBy')->willReturn(null);
self::assertNull($this->directory()->find('NOPE'));
}
/**
* @param list<CmsCalendarHotel>|null $fetched
*/
private function assertKeepsStoredList(?array $fetched): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$this->calendarHotelRepository->method('findAllIndexedByCode')->willReturn([
'DKI' => (new CalendarHotel())->setCode('DKI')->setName('Sportclub Schwendi'),
]);
$this->cmsDataProvider->method('fetchCalendarHotels')->willReturn($fetched);
$this->entityManager->expects(self::never())->method('remove');
$this->entityManager->expects(self::never())->method('flush');
$logger->expects(self::once())->method('warning');
self::assertFalse($this->directory($logger)->refresh());
}
private function directory(?LoggerInterface $logger = null): ContingentHotelDirectory
{
return new ContingentHotelDirectory(
$this->cmsDataProvider,
$this->accommodationRepository,
$this->calendarHotelRepository,
$this->entityManager,
$logger ?? $this->createStub(LoggerInterface::class),
);
}
}
+30 -29
View File
@@ -10,7 +10,6 @@ use App\BpnConnect\Model\ContingentCalendarEntry;
use App\BpnConnect\Model\ContingentCalendarMeta;
use App\BpnConnect\Model\ContingentCalendarResponse;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use App\Entity\Groups\ContingentSyncState;
use App\Repository\Groups\ContingentDayRepository;
@@ -40,16 +39,23 @@ class ContingentSnapshotManagerTest extends TestCase
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::Blocked),
]));
$this->entityManager->expects(self::exactly(3))->method('persist');
$persisted = [];
$this->entityManager->expects(self::exactly(3))->method('persist')
->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted[] = $entity;
});
$result = $this->sync();
$days = array_values(array_filter($persisted, static fn (object $entity) => $entity instanceof ContingentDay));
self::assertSame(['HOTEL1', 'HOTEL1'], array_map(static fn (ContingentDay $day) => $day->getHotelCode(), $days));
self::assertTrue($result->successful);
self::assertTrue($result->changed);
self::assertSame(2, $result->added);
@@ -67,7 +73,7 @@ class ContingentSnapshotManagerTest extends TestCase
'2026-07-03' => $this->day('2026-07-03', ContingentStatus::Ok),
];
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn($existing);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn($existing);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-02', ContingentStatus::OnRequest),
@@ -85,10 +91,10 @@ class ContingentSnapshotManagerTest extends TestCase
public function testAnIdenticalUpstreamResponseDoesNotMoveChangedAt(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
@@ -107,11 +113,11 @@ class ContingentSnapshotManagerTest extends TestCase
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$state = new ContingentSyncState('HOTEL1');
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->client->method('getContingentCalendar')->willThrowException(new BpnConnectException('upstream down'));
$this->dayRepository->expects(self::never())->method('findByAccommodationAndDateRange');
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
$this->entityManager->expects(self::never())->method('remove');
$result = $this->sync();
@@ -126,9 +132,9 @@ class ContingentSnapshotManagerTest extends TestCase
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$state = new ContingentSyncState($this->accommodation());
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
$state = new ContingentSyncState('HOTEL1');
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
]);
$this->client->method('getContingentCalendar')->willReturn($this->response([]));
@@ -143,7 +149,7 @@ class ContingentSnapshotManagerTest extends TestCase
public function testDaysOutsideTheRequestedWindowAreIgnored(): void
{
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-06-30', ContingentStatus::Ok),
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
@@ -157,11 +163,11 @@ class ContingentSnapshotManagerTest extends TestCase
public function testNearTermSyncDoesNotShrinkTheHorizonEstablishedByTheFullRun(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2028-08-20'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
@@ -174,11 +180,11 @@ class ContingentSnapshotManagerTest extends TestCase
public function testHorizonGrowsWhenAFurtherWindowIsSynced(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$state->recordSuccess(new \DateTimeImmutable('2026-08-20'), new \DateTimeImmutable('2026-01-01'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([]);
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
$this->client->method('getContingentCalendar')->willReturn($this->response([
new ContingentCalendarEntry('2026-07-01', ContingentStatus::Ok),
]));
@@ -190,11 +196,11 @@ class ContingentSnapshotManagerTest extends TestCase
public function testTheRollingWindowGrowingIsNotAChange(): void
{
$state = new ContingentSyncState($this->accommodation());
$state = new ContingentSyncState('HOTEL1');
$state->recordSuccess(new \DateTimeImmutable('2026-06-30'), new \DateTimeImmutable('2026-07-02'));
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($state);
$this->dayRepository->method('findByAccommodationAndDateRange')->willReturn([
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($state);
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-07-01' => $this->day('2026-07-01', ContingentStatus::Ok),
'2026-07-02' => $this->day('2026-07-02', ContingentStatus::Ok),
]);
@@ -225,17 +231,12 @@ class ContingentSnapshotManagerTest extends TestCase
);
return $manager->sync(
$this->accommodation(),
'HOTEL1',
new \DateTimeImmutable('2026-07-01'),
new \DateTimeImmutable('2026-07-03'),
);
}
private function accommodation(): Accommodation
{
return (new Accommodation())->setCalendarCode('HOTEL1');
}
private function day(string $date, ContingentStatus $status): ContingentDay
{
return (new ContingentDay())
+23 -10
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\ContingentDay;
use App\Entity\Groups\ContingentSyncState;
use App\Repository\Groups\ContingentDayRepository;
@@ -31,7 +30,7 @@ class ContingentSnapshotReaderTest extends TestCase
{
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->syncStateRepository->method('findOneByAccommodation')->willReturn(null);
$this->syncStateRepository->method('findOneByHotelCode')->willReturn(null);
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
self::assertNull($this->read());
@@ -41,7 +40,7 @@ class ContingentSnapshotReaderTest extends TestCase
{
$this->dayRepository = $this->createMock(ContingentDayRepository::class);
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-7 hours'));
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-7 hours'));
$this->dayRepository->expects(self::never())->method('findByHotelCodeAndDateRange');
self::assertNull($this->read());
@@ -49,7 +48,7 @@ class ContingentSnapshotReaderTest extends TestCase
public function testReturnsStatusesWhenRecentlySynced(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-10 minutes'));
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-10 minutes'));
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([
'2026-09-01' => $this->day(ContingentStatus::Ok),
'2026-09-02' => $this->day(ContingentStatus::OnRequest),
@@ -63,25 +62,39 @@ class ContingentSnapshotReaderTest extends TestCase
public function testStillUsableJustInsideTheStalenessWindow(): void
{
$this->syncStateRepository->method('findOneByAccommodation')->willReturn($this->state('-5 hours'));
$this->syncStateRepository->method('findOneByHotelCode')->willReturn($this->state('-5 hours'));
$this->dayRepository->method('findByHotelCodeAndDateRange')->willReturn([]);
self::assertSame([], $this->read());
}
public function testReturnsNullForAnAccommodationWithoutACalendarCode(): void
public function testReturnsNullForAnEmptyHotelCode(): void
{
$this->syncStateRepository = $this->createMock(ContingentSyncStateRepository::class);
$this->syncStateRepository->expects(self::never())->method('findOneByAccommodation');
$this->syncStateRepository->expects(self::never())->method('findOneByHotelCode');
self::assertNull($this->read(new Accommodation()));
self::assertNull($this->read(''));
}
public function testIsCurrentMatchesWhatStatusesForServes(): void
{
$reader = new ContingentSnapshotReader(
$this->dayRepository,
$this->syncStateRepository,
$this->createStub(LoggerInterface::class),
);
self::assertFalse($reader->isCurrent(null));
self::assertFalse($reader->isCurrent(new ContingentSyncState('HOTEL1')));
self::assertFalse($reader->isCurrent($this->state('-7 hours')));
self::assertTrue($reader->isCurrent($this->state('-5 hours')));
}
/**
* @return array<string, ContingentStatus>|null
*/
private function read(?Accommodation $accommodation = null): ?array
private function read(string $hotelCode = 'HOTEL1'): ?array
{
$reader = new ContingentSnapshotReader(
$this->dayRepository,
@@ -90,7 +103,7 @@ class ContingentSnapshotReaderTest extends TestCase
);
return $reader->statusesFor(
$accommodation ?? (new Accommodation())->setCalendarCode('HOTEL1'),
$hotelCode,
new \DateTimeImmutable('2026-09-01'),
new \DateTimeImmutable('2026-09-03'),
);