diff --git a/api.http b/api.http index 6f28aa8..3131572 100644 --- a/api.http +++ b/api.http @@ -136,24 +136,3 @@ GET {{base_url}}/api/pickups-planning/DPWWTS191125 Accept: application/json Content-Type: application/json Authorization: Bearer {{$auth.token("oauth2_api")}} - -### API contingents calendar (query refs) -# @no-cookie-jar -GET {{base_url}}/api/contingents/calendar?hotelRef=MVKGR2&dateFrom=2026-03-01&dateTo=2026-03-31 -Accept: application/json -Content-Type: application/json -Authorization: Bearer {{$auth.token("oauth2_api")}} - -### API contingents by date (query refs) -# @no-cookie-jar -GET {{base_url}}/api/contingents?hotelRef=L2ABDSFDOR&dateRef=SBW131225 -Accept: application/json -Content-Type: application/json -Authorization: Bearer {{$auth.token("oauth2_api")}} - -### API contingents rooms (query refs) -# @no-cookie-jar -GET {{base_url}}/api/contingents/rooms?hotelRef=L2ABDSFDOR&dateRef=SBW131225&dateFrom=2026-03-01&dateTo=2026-03-08 -Accept: application/json -Content-Type: application/json -Authorization: Bearer {{$auth.token("oauth2_api")}} diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index e8cbdbb..b60388b 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -3,21 +3,12 @@ flysystem: xml_export.storage: local: directory: '%env(resolve:XML_EXPORT_PATH)%' - xml_export_contingents.storage: - local: - directory: '%env(resolve:XML_EXPORT_CONTINGENTS_PATH)%' xml_source.storage: sftp: host: '%env(SFTP_XML_EXPORT_HOST)%' port: '%env(int:SFTP_XML_EXPORT_PORT)%' username: '%env(SFTP_XML_EXPORT_USER)%' password: '%env(SFTP_XML_EXPORT_PASSWORD)%' - xml_source_contingents.storage: - sftp: - host: '%env(SFTP_XML_EXPORT_CONTINGENTS_HOST)%' - port: '%env(int:SFTP_XML_EXPORT_CONTINGENTS_PORT)%' - username: '%env(SFTP_XML_EXPORT_CONTINGENTS_USER)%' - password: '%env(SFTP_XML_EXPORT_CONTINGENTS_PASSWORD)%' xml_dump.storage: local: directory: '%kernel.project_dir%/var/bpn' diff --git a/config/services.yaml b/config/services.yaml index 1665381..9066e3b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -112,14 +112,11 @@ services: arguments: $xmlSource: '@xml_source.storage' $xmlExport: '@xml_export.storage' - $xmlSourceContingents: '@xml_source_contingents.storage' - $xmlExportContingents: '@xml_export_contingents.storage' $bpnCache: '@bpn.cache' App\Command\BpnXmlCacheInvalidateCommand: arguments: $xmlExport: '@xml_export.storage' - $xmlExportContingents: '@xml_export_contingents.storage' $bpnCache: '@bpn.cache' $cache: '@cache.app' @@ -161,15 +158,6 @@ services: arguments: $cache: '@bpn.cache' - App\BusProNet\XmlLoader\ContingentLoader: - arguments: - $cache: '@bpn.cache' - $xmlExportContingents: '@xml_export_contingents.storage' - - App\BusProNet\Utility\BookingUrlUtility: - arguments: - $defaultBaseUrl: '%env(APP_BASE_URL)%' - App\Service\VoucherValidator: arguments: $cache: '@cache.app' diff --git a/src/BusProNet/Model/ContingentCalendarEvent.php b/src/BusProNet/Model/ContingentCalendarEvent.php deleted file mode 100644 index 277cb20..0000000 --- a/src/BusProNet/Model/ContingentCalendarEvent.php +++ /dev/null @@ -1,22 +0,0 @@ - $dateId, - 'hotel_id' => $hotelId, - ], '', '&', PHP_QUERY_RFC3986); - - $baseUrlInput = null !== $myEpUrl && '' !== trim($myEpUrl) - ? $myEpUrl - : $this->defaultBaseUrl; - $baseUrl = $this->normalizeBaseUrl($baseUrlInput); - if ('' === $baseUrl) { - return sprintf('/bookings/create?%s', $query); - } - - return sprintf('%s/bookings/create?%s', $baseUrl, $query); - } - - private function normalizeBaseUrl(string $url): string - { - $trimmedUrl = trim($url); - - return rtrim($trimmedUrl, '/'); - } -} diff --git a/src/BusProNet/XmlLoader/ContingentLoader.php b/src/BusProNet/XmlLoader/ContingentLoader.php deleted file mode 100644 index f5e3e36..0000000 --- a/src/BusProNet/XmlLoader/ContingentLoader.php +++ /dev/null @@ -1,127 +0,0 @@ - - */ - public function generateFilesMap(): array - { - try { - return $this->cache->get('bpn_contingent_files', function (ItemInterface $item) { - $item->expiresAfter(3 * 60 * 60); - $item->tag(['xml-sync']); - - $xmlFiles = $this->xmlExport - ->listContents('.') - ->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'HotelZimmer_')); - - $mapping = []; - - foreach ($xmlFiles as $file) { - if (1 !== preg_match('/HotelZimmer_(\d+)\.xml$/', $file->path(), $matches)) { - continue; - } - - $mapping[(int) $matches[1]] = $file->path(); - } - - return $mapping; - }); - } catch (InvalidArgumentException) { - return []; - } - } - - /** - * @return array{roomTypes: array>, rows: array>} - */ - public function loadByHotelId(int $hotelId): array - { - $cacheKey = sprintf('contingent_hotel_%d', $hotelId); - - try { - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($hotelId) { - $item->expiresAfter(3600); - $item->tag(['xml-sync']); - - return $this->loadByHotelIdUncached($hotelId); - }); - } catch (InvalidArgumentException) { - return $this->loadByHotelIdUncached($hotelId); - } - } - - /** - * @return array{roomTypes: array>, rows: array>} - */ - private function loadByHotelIdUncached(int $hotelId): array - { - $mapping = $this->generateFilesMap(); - - if (false === isset($mapping[$hotelId])) { - return ['roomTypes' => [], 'rows' => []]; - } - - $filename = $mapping[$hotelId]; - - try { - $xml = $this->xmlExport->read($filename); - } catch (FilesystemException) { - return ['roomTypes' => [], 'rows' => []]; - } - - $linkedHotelId = $this->getRootLinkedHotelId($xml); - if (null !== $linkedHotelId) { - if (false === isset($mapping[$linkedHotelId])) { - return ['roomTypes' => [], 'rows' => []]; - } - - try { - $xml = $this->xmlExport->read($mapping[$linkedHotelId]); - } catch (FilesystemException) { - return ['roomTypes' => [], 'rows' => []]; - } - } - - return $this->parser->parse($xml); - } - - private function getRootLinkedHotelId(string $xml): ?int - { - $document = new \DOMDocument(); - if (false === @$document->loadXML($xml)) { - return null; - } - - $root = $document->documentElement; - if (null === $root) { - return null; - } - - $link = $root->getAttribute('idbuspro_kontingent_aus'); - if ('' === $link) { - return null; - } - - return (int) $link; - } -} diff --git a/src/BusProNet/XmlParser/ContingentParser.php b/src/BusProNet/XmlParser/ContingentParser.php deleted file mode 100644 index b36473b..0000000 --- a/src/BusProNet/XmlParser/ContingentParser.php +++ /dev/null @@ -1,151 +0,0 @@ - - */ - private array $ignoredRoomCodes = [ - 'PDGS', - ]; - - /** - * @return array{roomTypes: array>, rows: array>} - */ - public function parse(string $xml): array - { - $crawler = new Crawler($xml); - $roomTypes = $this->parseRoomTypes($crawler); - $rows = $this->parseRows($crawler, $roomTypes); - - return [ - 'roomTypes' => $roomTypes, - 'rows' => $rows, - ]; - } - - /** - * @return array> - */ - private function parseRoomTypes(Crawler $crawler): array - { - $roomTypes = []; - - $crawler - ->filterXPath('//unterbringungen/unterbringung') - ->each(function (Crawler $node) use (&$roomTypes) { - $roomId = $this->getIntOrNullAttribute($node->attr('idbuspro')); - $code = $node->attr('code'); - - if (null === $roomId || null === $code) { - return; - } - - if (true === in_array($code, $this->ignoredRoomCodes, true)) { - return; - } - - $label = $node->attr('zimmerbezeichnung') - ?? $this->getStringOrNullValue($node->filterXPath('.//text')) - ?? $code; - - $pax = $this->getIntOrNullAttribute($node->attr('pax_max')) ?? 0; - $link = $this->getIntOrNullAttribute($node->attr('idbuspro_kontingent_aus')); - - $roomTypes[$roomId] = [ - 'idBusPro' => $roomId, - 'code' => $code, - 'pax' => $pax, - 'label' => $label, - 'isControlRoom' => self::CONTROL_ROOM_CODE === $code, - 'link' => $link, - ]; - }); - - return $roomTypes; - } - - /** - * @param array> $roomTypes - * - * @return array> - */ - private function parseRows(Crawler $crawler, array $roomTypes): array - { - $rows = []; - - $crawler - ->filterXPath('//kapazitaeten/kapazitaet') - ->each(function (Crawler $dateNode) use (&$rows, $roomTypes) { - $date = $this->stringToDate($dateNode->attr('termin')); - - if (null === $date) { - return; - } - - foreach ($roomTypes as $roomType) { - $roomId = $roomType['link'] ?? $roomType['idBusPro']; - - $roomNode = $this->findRoomNode($dateNode, $roomId); - if (null === $roomNode) { - continue; - } - - $capacity = $this->getIntOrNullAttribute($roomNode->attr('kontingent')) ?? 0; - $free = $this->getIntOrNullAttribute($roomNode->attr('frei')) ?? 0; - $statusRaw = $roomNode->attr('status') ?? ''; - - $rows[] = [ - 'date' => $date, - 'roomCode' => $roomType['code'], - 'roomLabel' => $roomType['label'], - 'pax' => $roomType['isControlRoom'] ? 0 : $capacity * (int) ($roomType['pax'] ?? 0), - 'available' => $roomType['isControlRoom'] ? 0 : $free * (int) ($roomType['pax'] ?? 0), - 'status' => $this->mapStatus($statusRaw), - 'minPrice' => $this->stringToFloat($roomNode->attr('abpreis')), - 'minNights' => $this->getIntOrNullAttribute($roomNode->attr('abpreis_naechte')), - 'additionalNightMinPrice' => $this->stringToFloat($roomNode->attr('abpreis_verlaengerung')), - 'additionalNightMinNights' => $this->getIntOrNullAttribute($roomNode->attr('abpreis_verlaengerung_naechte')), - 'isControlRoom' => $roomType['isControlRoom'], - ]; - } - }); - - return $rows; - } - - private function findRoomNode(Crawler $dateNode, int $roomId): ?Crawler - { - $roomNode = $dateNode->filterXPath(sprintf('.//zimmerliste/zimmer[@idbuspro="%d"]', $roomId)); - if (0 < $roomNode->count()) { - return $roomNode->first(); - } - - $roomNode = $dateNode->filterXPath(sprintf('.//zimmerliste/zimmer[@id="%d"]', $roomId)); - if (0 < $roomNode->count()) { - return $roomNode->first(); - } - - $roomNode = $dateNode->filterXPath(sprintf('.//zimmerliste/zimmer[@id_zimmer="%d"]', $roomId)); - if (0 < $roomNode->count()) { - return $roomNode->first(); - } - - return null; - } - - private function mapStatus(?string $status): string - { - return match (strtoupper((string) $status)) { - 'A' => 'ON_REQUEST', - 'S' => 'BLOCKED', - default => 'OK', - }; - } -} diff --git a/src/Command/BpnXmlCacheInvalidateCommand.php b/src/Command/BpnXmlCacheInvalidateCommand.php index 61e36eb..4a18e28 100644 --- a/src/Command/BpnXmlCacheInvalidateCommand.php +++ b/src/Command/BpnXmlCacheInvalidateCommand.php @@ -31,7 +31,6 @@ class BpnXmlCacheInvalidateCommand extends Command public function __construct( private readonly FilesystemOperator $xmlExport, - private readonly FilesystemOperator $xmlExportContingents, private readonly TagAwareCacheInterface $bpnCache, private readonly CacheInterface $cache, private readonly LoggerInterface $logger, @@ -194,10 +193,6 @@ class BpnXmlCacheInvalidateCommand extends Command 'name' => 'travel', 'storage' => $this->xmlExport, ], - [ - 'name' => 'contingents', - 'storage' => $this->xmlExportContingents, - ], ]; } } diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php deleted file mode 100644 index a20009c..0000000 --- a/src/Controller/Api/ContingentController.php +++ /dev/null @@ -1,242 +0,0 @@ -query->get('hotelRef'); - - if (null === $hotelReference || '' === trim($hotelReference)) { - return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST); - } - - return $this->handleCalendar($request, $hotelReference); - } - - #[Route( - '/contingents', - name: 'api_contingents_single', - methods: ['GET'], - )] - public function byDate(Request $request): JsonResponse - { - $hotelReference = $request->query->get('hotelRef'); - $dateReference = $request->query->get('dateRef'); - - if (null === $hotelReference || '' === trim($hotelReference)) { - return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST); - } - - if (null === $dateReference || '' === trim($dateReference)) { - return $this->json(['error' => 'dateRef is required'], Response::HTTP_BAD_REQUEST); - } - - return $this->handleByDate($hotelReference, $dateReference); - } - - #[Route( - '/contingents/rooms', - name: 'api_contingents_rooms', - methods: ['GET'], - )] - public function rooms(Request $request): JsonResponse - { - $hotelReference = $request->query->get('hotelRef'); - $dateReference = $request->query->get('dateRef'); - $dateFrom = $request->query->get('dateFrom'); - $dateTo = $request->query->get('dateTo'); - $myEpUrl = $request->query->get('my_ep_url'); - - if (null === $hotelReference || '' === trim($hotelReference)) { - return $this->json(['error' => 'hotelRef is required'], Response::HTTP_BAD_REQUEST); - } - - if (null === $dateReference || '' === trim($dateReference)) { - return $this->json(['error' => 'dateRef is required'], Response::HTTP_BAD_REQUEST); - } - - if (null === $dateFrom || null === $dateTo) { - return $this->json(['error' => 'dateFrom and dateTo are required'], Response::HTTP_BAD_REQUEST); - } - - return $this->handleRooms( - $hotelReference, - $dateReference, - $dateFrom, - $dateTo, - is_string($myEpUrl) ? $myEpUrl : null, - ); - } - - /** - * Shared execution path for calendar data after hotel reference validation. - */ - private function handleCalendar(Request $request, string $hotelReference): JsonResponse - { - $hotelId = $this->resolveHotelId($hotelReference); - - if (null === $hotelId) { - return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND); - } - - $dateFrom = $request->query->get('dateFrom'); - $dateTo = $request->query->get('dateTo'); - - if (null === $dateFrom || null === $dateTo) { - return $this->json(['error' => 'dateFrom and dateTo are required'], Response::HTTP_BAD_REQUEST); - } - - try { - $events = $this->contingentDataService->getCalendarEvents($hotelId, $dateFrom, $dateTo); - } catch (\InvalidArgumentException $e) { - return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); - } - - return $this->json($events, Response::HTTP_OK, [], ['groups' => ['api:contingent']]); - } - - /** - * Shared execution path for daily contingent summary after reference validation. - */ - private function handleByDate(string $hotelReference, string $dateReference): JsonResponse - { - $hotelId = $this->resolveHotelId($hotelReference); - $dateId = $this->resolveDateId($dateReference); - - if (null === $hotelId || null === $dateId) { - return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND); - } - - try { - $contingents = $this->contingentDataService->getAvailableContingents($hotelId, $dateId); - } catch (TravelNotFoundException|HotelNotInTravelException) { - return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND); - } - - return $this->json($contingents, Response::HTTP_OK, [], ['groups' => ['api:contingent']]); - } - - /** - * Shared execution path for room-level availability after reference validation. - */ - private function handleRooms( - string $hotelReference, - string $dateReference, - string $dateFrom, - string $dateTo, - ?string $myEpUrl = null, - ): JsonResponse { - $hotelId = $this->resolveHotelId($hotelReference); - $dateId = $this->resolveDateId($dateReference); - - if (null === $hotelId || null === $dateId) { - return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND); - } - - try { - $rooms = $this->contingentDataService->getAvailableRooms($dateFrom, $dateTo, $hotelId, $dateId, $myEpUrl); - } catch (TravelNotFoundException|HotelNotInTravelException) { - return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND); - } catch (\InvalidArgumentException $e) { - return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); - } - - return $this->json($rooms, Response::HTTP_OK, [], ['groups' => ['api:contingent']]); - } - - /** - * Resolves a hotel reference to hotel ID. - * - * Numeric values are treated as IDs, otherwise mapped as hotel code. - */ - private function resolveHotelId(string $hotelReference): ?int - { - return $this->resolveReferenceId( - $hotelReference, - fn (string $reference): ?int => $this->travelDataProvider->mapHotelCodeToId($reference), - ); - } - - /** - * Resolves a date reference to date ID. - * - * Numeric values are treated as IDs, otherwise mapped as date code. - * Date codes are sanitized first (uppercased and separators removed). - */ - private function resolveDateId(string $dateReference): ?int - { - return $this->resolveReferenceId( - $dateReference, - fn (string $reference): ?int => $this->travelDataProvider->mapDateCodeToId($reference), - fn (string $reference): string => (new DateCodeUtility())->sanitize($reference), - ); - } - - /** - * Generic resolver for mixed ID/code references. - * - * Flow: - * 1. Trim and reject empty values. - * 2. If numeric, return as integer ID. - * 3. Optionally normalize the value. - * 4. Map normalized code to ID. - * - * @param callable $mapper maps code input to ID - * @param callable|null $normalizer optional code normalizer before mapping - */ - private function resolveReferenceId( - string $reference, - callable $mapper, - ?callable $normalizer = null, - ): ?int { - $trimmedReference = trim($reference); - - if ('' === $trimmedReference) { - return null; - } - - if (true === ctype_digit($trimmedReference)) { - return (int) $trimmedReference; - } - - if (null !== $normalizer) { - $trimmedReference = $normalizer($trimmedReference); - } - - return $mapper($trimmedReference); - } -} diff --git a/src/Service/BpnXmlSyncManager.php b/src/Service/BpnXmlSyncManager.php index 5c10322..4cf85be 100644 --- a/src/Service/BpnXmlSyncManager.php +++ b/src/Service/BpnXmlSyncManager.php @@ -21,8 +21,6 @@ final class BpnXmlSyncManager public function __construct( private readonly FilesystemOperator $xmlSource, private readonly FilesystemOperator $xmlExport, - private readonly FilesystemOperator $xmlSourceContingents, - private readonly FilesystemOperator $xmlExportContingents, private readonly TagAwareCacheInterface $bpnCache, private readonly LoggerInterface $logger, ) { @@ -35,7 +33,6 @@ final class BpnXmlSyncManager { return [ new BpnXmlSyncTarget('travel', $this->xmlSource, $this->xmlExport), - new BpnXmlSyncTarget('contingents', $this->xmlSourceContingents, $this->xmlExportContingents), ]; } diff --git a/src/Service/ContingentDataService.php b/src/Service/ContingentDataService.php deleted file mode 100644 index dbcd507..0000000 --- a/src/Service/ContingentDataService.php +++ /dev/null @@ -1,258 +0,0 @@ - 0, - 'ON_REQUEST' => 1, - 'BLOCKED' => 2, - ]; - - public function __construct( - private readonly ContingentLoader $contingentLoader, - private readonly TravelLoader $travelLoader, - private readonly BookingUrlUtility $bookingUrlUtility, - ) { - } - - /** - * @return array - * - * @throws TravelNotFoundException - * @throws HotelNotInTravelException - */ - public function getAvailableContingents(int $hotelId, int $dateId): array - { - $travel = $this->travelLoader->loadById($dateId, $hotelId); - - if (null === $travel->dateFrom || null === $travel->dateTo) { - return []; - } - - $data = $this->contingentLoader->loadByHotelId($hotelId); - $rows = $this->filterRowsByRange($data['rows'], $travel->dateFrom, $travel->dateTo); - $belKalStatusByDate = $this->getBelKalStatusByDate($rows); - - $result = []; - - foreach ($rows as $row) { - if (true === ($row['isControlRoom'] ?? false)) { - continue; - } - - $dateKey = $row['date']->format('Y-m-d'); - - if (false === isset($result[$dateKey])) { - $result[$dateKey] = new ContingentDateSummary($dateKey, null, 0, 0); - } - - if (null !== $row['minNights']) { - $result[$dateKey]->minNights = null === $result[$dateKey]->minNights - ? $row['minNights'] - : min($result[$dateKey]->minNights, $row['minNights']); - } - - $result[$dateKey]->total += (int) ($row['available'] ?? 0); - $result[$dateKey]->capacity += (int) ($row['pax'] ?? 0); - } - - foreach ($belKalStatusByDate as $dateKey => $status) { - if (false === isset($result[$dateKey])) { - continue; - } - - if ('OK' !== $status) { - $result[$dateKey]->total = 0; - } - } - - return $result; - } - - /** - * @return array - * - * @throws TravelNotFoundException - * @throws HotelNotInTravelException - * @throws \InvalidArgumentException - */ - public function getAvailableRooms( - string $dateFrom, - string $dateTo, - int $hotelId, - int $dateId, - ?string $myEpUrl = null, - ): array { - $range = $this->parseDateRange($dateFrom, $dateTo); - $travel = $this->travelLoader->loadById($dateId, $hotelId); - - if (null !== $travel->dateFrom && null !== $travel->dateTo) { - if ($range['from'] < $travel->dateFrom || $range['to'] > $travel->dateTo) { - throw new \InvalidArgumentException('Requested range must be within the travel date range'); - } - } - - $data = $this->contingentLoader->loadByHotelId($hotelId); - $rows = $this->filterRowsByRange($data['rows'], $range['from'], $range['to']); - $belKalStatusByDate = $this->getBelKalStatusByDate($rows); - - $nights = $range['from']->diff($range['to'])->days; - $bookingUrl = $this->bookingUrlUtility->build($hotelId, $dateId, $myEpUrl); - $result = []; - - foreach ($rows as $row) { - if (true === ($row['isControlRoom'] ?? false)) { - continue; - } - - $dateKey = $row['date']->format('Y-m-d'); - $status = $row['status'] ?? 'OK'; - - if (isset($belKalStatusByDate[$dateKey]) && 'OK' !== $belKalStatusByDate[$dateKey]) { - $status = $belKalStatusByDate[$dateKey]; - } - - $minNights = $row['minNights'] ?? 0; - $additionalNightMinPrice = $row['additionalNightMinPrice'] ?? 0.0; - $minPrice = $row['minPrice'] ?? 0.0; - $extraNights = max(0, $nights - (int) $minNights); - - $priceForSelection = $minPrice + ($extraNights * $additionalNightMinPrice); - - $result[] = new ContingentRoomAvailability( - date: $dateKey, - roomCode: (string) $row['roomCode'], - roomLabel: (string) $row['roomLabel'], - bookingUrl: $bookingUrl, - pax: (int) ($row['pax'] ?? 0), - available: 'OK' === $status ? (int) ($row['available'] ?? 0) : 0, - status: $status, - minPrice: $row['minPrice'], - minNights: $row['minNights'], - additionalNightMinPrice: $row['additionalNightMinPrice'], - additionalNightMinNights: $row['additionalNightMinNights'], - priceForSelection: (float) $priceForSelection, - ); - } - - return $result; - } - - /** - * @return array - */ - public function getCalendarEvents(int $hotelId, string $dateFrom, string $dateTo): array - { - $range = $this->parseDateRange($dateFrom, $dateTo); - $data = $this->contingentLoader->loadByHotelId($hotelId); - $rows = $this->filterRowsByRange($data['rows'], $range['from'], $range['to']); - - $events = []; - - foreach ($rows as $row) { - $dateKey = $row['date']->format('Y-m-d'); - - if (false === isset($events[$dateKey])) { - $events[$dateKey] = new ContingentCalendarEvent($dateKey, 'OK', 0, 0); - } - - $events[$dateKey]->status = $this->mergeStatus( - $events[$dateKey]->status, - $row['status'] ?? 'OK' - ); - - if (false === ($row['isControlRoom'] ?? false)) { - $events[$dateKey]->pax += (int) ($row['pax'] ?? 0); - $events[$dateKey]->available += (int) ($row['available'] ?? 0); - } - } - - ksort($events); - - return array_values($events); - } - - /** - * @param array> $rows - * - * @return array> - */ - private function filterRowsByRange(array $rows, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): array - { - return array_values(array_filter($rows, function (array $row) use ($dateFrom, $dateTo) { - if (false === isset($row['date']) || !$row['date'] instanceof \DateTimeImmutable) { - return false; - } - - return $row['date'] >= $dateFrom && $row['date'] <= $dateTo; - })); - } - - /** - * @param array> $rows - * - * @return array - */ - private function getBelKalStatusByDate(array $rows): array - { - $statuses = []; - - foreach ($rows as $row) { - if (false === ($row['isControlRoom'] ?? false)) { - continue; - } - - $dateKey = $row['date']->format('Y-m-d'); - $statuses[$dateKey] = $this->mergeStatus($statuses[$dateKey] ?? 'OK', $row['status'] ?? 'OK'); - } - - return $statuses; - } - - private function mergeStatus(string $current, string $candidate): string - { - $currentOrder = self::STATUS_ORDER[$current] ?? 0; - $candidateOrder = self::STATUS_ORDER[$candidate] ?? 0; - - return $candidateOrder > $currentOrder ? $candidate : $current; - } - - /** - * @return array{from: \DateTimeImmutable, to: \DateTimeImmutable} - */ - private function parseDateRange(string $dateFrom, string $dateTo): array - { - $from = $this->parseDate($dateFrom); - $to = $this->parseDate($dateTo); - - if ($to < $from) { - throw new \InvalidArgumentException('dateTo must be on or after dateFrom'); - } - - return ['from' => $from, 'to' => $to]; - } - - private function parseDate(string $date): \DateTimeImmutable - { - $parsed = \DateTimeImmutable::createFromFormat('Y-m-d', $date); - - if (false === $parsed || $parsed->format('Y-m-d') !== $date) { - throw new \InvalidArgumentException('Invalid date format, expected Y-m-d'); - } - - return $parsed->setTime(0, 0, 0); - } -} diff --git a/tests/BusProNet/Utility/BookingUrlUtilityTest.php b/tests/BusProNet/Utility/BookingUrlUtilityTest.php deleted file mode 100644 index 9fcc367..0000000 --- a/tests/BusProNet/Utility/BookingUrlUtilityTest.php +++ /dev/null @@ -1,56 +0,0 @@ -build(157047, 11606); - - $this->assertSame( - 'https://my.ep-reisen.de/bookings/create?date_id=11606&hotel_id=157047', - $url - ); - } - - public function testBuildUsesCustomBaseUrlWhenProvided(): void - { - $utility = new BookingUrlUtility('https://my.ep-reisen.de'); - - $url = $utility->build(157047, 11606, 'https://my.example.test/'); - - $this->assertSame( - 'https://my.example.test/bookings/create?date_id=11606&hotel_id=157047', - $url - ); - } - - public function testBuildFallsBackToConfiguredDefaultWhenCustomBaseIsBlank(): void - { - $utility = new BookingUrlUtility('https://fallback.test'); - - $url = $utility->build(157047, 11606, ' '); - - $this->assertSame( - 'https://fallback.test/bookings/create?date_id=11606&hotel_id=157047', - $url - ); - } - - public function testBuildReturnsRelativeUrlWhenNoBaseUrlAvailable(): void - { - $utility = new BookingUrlUtility(''); - - $url = $utility->build(157047, 11606); - - $this->assertSame('/bookings/create?date_id=11606&hotel_id=157047', $url); - } -} diff --git a/tests/BusProNet/XmlLoader/ContingentLoaderTest.php b/tests/BusProNet/XmlLoader/ContingentLoaderTest.php deleted file mode 100644 index cccdd15..0000000 --- a/tests/BusProNet/XmlLoader/ContingentLoaderTest.php +++ /dev/null @@ -1,137 +0,0 @@ -createMock(TagAwareCacheInterface::class); - $filesystem = $this->createMock(FilesystemOperator::class); - $parser = $this->createMock(ContingentParser::class); - - $filesystem->method('listContents')->willReturn(new DirectoryListing([ - new FileAttributes('HotelZimmer_100.xml', 10), - new FileAttributes('HotelZimmer_200.xml', 10), - new FileAttributes('other.xml', 10), - ])); - - $item = $this->createMock(ItemInterface::class); - $item->expects($this->once())->method('expiresAfter')->with(3 * 60 * 60); - $item->expects($this->once())->method('tag')->with(['xml-sync']); - - $cache->expects($this->once()) - ->method('get') - ->with('bpn_contingent_files', $this->isType('callable')) - ->willReturnCallback(fn (string $key, callable $callback) => $callback($item)); - - $loader = new ContingentLoader($parser, $cache, $filesystem); - $map = $loader->generateFilesMap(); - - $this->assertSame([ - 100 => 'HotelZimmer_100.xml', - 200 => 'HotelZimmer_200.xml', - ], $map); - } - - public function testLoadByHotelIdUsesRootLinkOneHopAndTagsCache(): void - { - $cache = $this->createMock(TagAwareCacheInterface::class); - $filesystem = $this->createMock(FilesystemOperator::class); - $parser = $this->createMock(ContingentParser::class); - - $originalXml = ''; - $linkedXml = ''; - $parsed = ['roomTypes' => [], 'rows' => [['roomCode' => 'DZ']]]; - - $filesystem->method('listContents')->willReturn(new DirectoryListing([ - new FileAttributes('HotelZimmer_100.xml', 10), - new FileAttributes('HotelZimmer_200.xml', 10), - ])); - $filesystem->method('read')->willReturnMap([ - ['HotelZimmer_100.xml', $originalXml], - ['HotelZimmer_200.xml', $linkedXml], - ]); - - $mapItem = $this->createMock(ItemInterface::class); - $mapItem->method('expiresAfter')->with(3 * 60 * 60); - $mapItem->method('tag')->with(['xml-sync']); - - $hotelItem = $this->createMock(ItemInterface::class); - $hotelItem->method('expiresAfter')->with(3600); - $hotelItem->method('tag')->with(['xml-sync']); - - $cache->method('get')->willReturnCallback( - function (string $key, callable $callback) use ($mapItem, $hotelItem) { - if ('bpn_contingent_files' === $key) { - return $callback($mapItem); - } - if ('contingent_hotel_100' === $key) { - return $callback($hotelItem); - } - - return null; - } - ); - - $parser->expects($this->once())->method('parse')->with($linkedXml)->willReturn($parsed); - - $loader = new ContingentLoader($parser, $cache, $filesystem); - $result = $loader->loadByHotelId(100); - - $this->assertSame($parsed, $result); - } - - public function testLoadByHotelIdReturnsEmptyWhenLinkedTargetIsMissing(): void - { - $cache = $this->createMock(TagAwareCacheInterface::class); - $filesystem = $this->createMock(FilesystemOperator::class); - $parser = $this->createMock(ContingentParser::class); - - $xml = ''; - - $filesystem->method('listContents')->willReturn(new DirectoryListing([ - new FileAttributes('HotelZimmer_100.xml', 10), - ])); - $filesystem->method('read')->willReturn($xml); - - $mapItem = $this->createMock(ItemInterface::class); - $mapItem->method('expiresAfter')->with(3 * 60 * 60); - $mapItem->method('tag')->with(['xml-sync']); - - $hotelItem = $this->createMock(ItemInterface::class); - $hotelItem->method('expiresAfter')->with(3600); - $hotelItem->method('tag')->with(['xml-sync']); - - $cache->method('get')->willReturnCallback( - function (string $key, callable $callback) use ($mapItem, $hotelItem) { - if ('bpn_contingent_files' === $key) { - return $callback($mapItem); - } - if ('contingent_hotel_100' === $key) { - return $callback($hotelItem); - } - - return null; - } - ); - - $parser->expects($this->never())->method('parse'); - - $loader = new ContingentLoader($parser, $cache, $filesystem); - $result = $loader->loadByHotelId(100); - - $this->assertSame(['roomTypes' => [], 'rows' => []], $result); - } -} diff --git a/tests/BusProNet/XmlParser/ContingentParserTest.php b/tests/BusProNet/XmlParser/ContingentParserTest.php deleted file mode 100644 index 8fb1e78..0000000 --- a/tests/BusProNet/XmlParser/ContingentParserTest.php +++ /dev/null @@ -1,102 +0,0 @@ -parser = new ContingentParser(); - } - - public function testParseBuildsRoomTypesAndRowsWithLinksAndControlRoom(): void - { - $xml = <<<'XML' - - - - - - - - - - - - - - - - - -XML; - - $result = $this->parser->parse($xml); - - $this->assertArrayHasKey('roomTypes', $result); - $this->assertArrayHasKey('rows', $result); - $this->assertCount(3, $result['roomTypes']); // PDGS skipped - $this->assertCount(3, $result['rows']); - - $rowsByCode = []; - foreach ($result['rows'] as $row) { - $rowsByCode[$row['roomCode']] = $row; - } - - $this->assertSame('OK', $rowsByCode['DZ']['status']); - $this->assertSame(6, $rowsByCode['DZ']['pax']); - $this->assertSame(2, $rowsByCode['DZ']['available']); - $this->assertSame(100.0, $rowsByCode['DZ']['minPrice']); - $this->assertSame(2, $rowsByCode['DZ']['minNights']); - $this->assertSame(15.0, $rowsByCode['DZ']['additionalNightMinPrice']); - $this->assertSame(1, $rowsByCode['DZ']['additionalNightMinNights']); - $this->assertFalse($rowsByCode['DZ']['isControlRoom']); - - // Linked room uses DZ contingent node but its own pax - $this->assertSame('Single Room', $rowsByCode['EZ']['roomLabel']); - $this->assertSame(3, $rowsByCode['EZ']['pax']); - $this->assertSame(1, $rowsByCode['EZ']['available']); - - $this->assertSame('ON_REQUEST', $rowsByCode['BelKal']['status']); - $this->assertSame(0, $rowsByCode['BelKal']['pax']); - $this->assertSame(0, $rowsByCode['BelKal']['available']); - $this->assertTrue($rowsByCode['BelKal']['isControlRoom']); - } - - public function testParseHandlesZeroValuesAndBlockedStatus(): void - { - $xml = <<<'XML' - - - - - - - - - - - - - -XML; - - $result = $this->parser->parse($xml); - $row = $result['rows'][0]; - - $this->assertSame('BLOCKED', $row['status']); - $this->assertSame(0, $row['pax']); - $this->assertSame(0, $row['available']); - $this->assertSame(0.0, $row['minPrice']); - $this->assertSame(0, $row['minNights']); - $this->assertSame(0.0, $row['additionalNightMinPrice']); - $this->assertSame(0, $row['additionalNightMinNights']); - } -} diff --git a/tests/Command/BpnXmlSyncCommandTest.php b/tests/Command/BpnXmlSyncCommandTest.php index 25432bb..86218a1 100644 --- a/tests/Command/BpnXmlSyncCommandTest.php +++ b/tests/Command/BpnXmlSyncCommandTest.php @@ -6,13 +6,12 @@ namespace App\Tests\Command; use App\BusProNet\XmlLoader\TravelLoader; use App\Command\BpnXmlSyncCommand; -use App\Service\BpnXmlSyncManager; use App\Service\BpnXmlSnapshotRefreshManager; +use App\Service\BpnXmlSyncManager; use App\Service\TravelDataProvider; use App\Service\TravelSnapshotManager; use League\Flysystem\DirectoryListing; use League\Flysystem\FilesystemOperator; -use League\Flysystem\UnableToListContents; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; @@ -23,8 +22,6 @@ class BpnXmlSyncCommandTest extends TestCase { private FilesystemOperator $xmlSource; private FilesystemOperator $xmlExport; - private FilesystemOperator $xmlSourceContingents; - private FilesystemOperator $xmlExportContingents; private TagAwareCacheInterface $cache; private LoggerInterface $logger; private TravelLoader $travelLoader; @@ -36,7 +33,6 @@ class BpnXmlSyncCommandTest extends TestCase protected function tearDown(): void { - // Release the filesystem lock held by LockableTrait so subsequent tests can acquire it. $release = new \ReflectionMethod($this->command, 'release'); $release->setAccessible(true); $release->invoke($this->command); @@ -46,8 +42,6 @@ class BpnXmlSyncCommandTest extends TestCase { $this->xmlSource = $this->createMock(FilesystemOperator::class); $this->xmlExport = $this->createMock(FilesystemOperator::class); - $this->xmlSourceContingents = $this->createMock(FilesystemOperator::class); - $this->xmlExportContingents = $this->createMock(FilesystemOperator::class); $this->cache = $this->createMock(TagAwareCacheInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->travelLoader = $this->createMock(TravelLoader::class); @@ -63,8 +57,6 @@ class BpnXmlSyncCommandTest extends TestCase $this->syncManager = new BpnXmlSyncManager( $this->xmlSource, $this->xmlExport, - $this->xmlSourceContingents, - $this->xmlExportContingents, $this->cache, $this->logger, ); @@ -76,9 +68,6 @@ class BpnXmlSyncCommandTest extends TestCase ); } - /** - * Remote newer than local -> files downloaded -> syncSnapshotsFromXml() called once. - */ public function testSnapshotSyncIsTriggeredAfterSuccessfulFileDownload(): void { $newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n"; @@ -90,12 +79,6 @@ class BpnXmlSyncCommandTest extends TestCase $this->xmlSource->method('listContents')->willReturn(new DirectoryListing([])); $this->xmlExport->method('listContents')->willReturn(new DirectoryListing([])); - $this->xmlSourceContingents->method('read')->willReturn($olderTimestamp); - $this->xmlExportContingents->method('fileExists')->willReturn(true); - $this->xmlExportContingents->method('read')->willReturn($olderTimestamp); - $this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([])); - $this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([])); - $fileMap = [ 101 => ['hotels' => ['H1' => null, 'H2' => null]], 102 => ['hotels' => ['H3' => null]], @@ -124,9 +107,6 @@ class BpnXmlSyncCommandTest extends TestCase $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); } - /** - * Local already up to date -> early return before snapshot code is reached. - */ public function testSnapshotSyncIsSkippedWhenLocalDataIsUpToDate(): void { $sameTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n"; @@ -136,12 +116,6 @@ class BpnXmlSyncCommandTest extends TestCase $this->xmlSource->method('listContents')->willReturn(new DirectoryListing([])); $this->xmlExport->method('listContents')->willReturn(new DirectoryListing([])); - $this->xmlSourceContingents->method('read')->willReturn($sameTimestamp); - $this->xmlExportContingents->method('fileExists')->willReturn(true); - $this->xmlExportContingents->method('read')->willReturn($sameTimestamp); - $this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([])); - $this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([])); - $this->travelLoader->expects($this->never())->method('generateFilesMap'); $this->travelDataService->expects($this->never())->method('syncSnapshotsFromXml'); $this->travelSnapshotService->expects($this->never())->method('purgeOrphanedFutureSnapshots'); @@ -153,10 +127,6 @@ class BpnXmlSyncCommandTest extends TestCase $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); } - /** - * generateFilesMap() throws -> warning logged, syncSnapshotsFromXml() never called, - * command still returns SUCCESS. - */ public function testSnapshotSyncIsSkippedAndWarningLoggedWhenFileMapGenerationFails(): void { $newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n"; @@ -168,12 +138,6 @@ class BpnXmlSyncCommandTest extends TestCase $this->xmlSource->method('listContents')->willReturn(new DirectoryListing([])); $this->xmlExport->method('listContents')->willReturn(new DirectoryListing([])); - $this->xmlSourceContingents->method('read')->willReturn($olderTimestamp); - $this->xmlExportContingents->method('fileExists')->willReturn(true); - $this->xmlExportContingents->method('read')->willReturn($olderTimestamp); - $this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([])); - $this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([])); - $this->travelLoader->expects($this->once()) ->method('generateFilesMap') ->willThrowException(new \RuntimeException('Storage unavailable')); @@ -194,84 +158,4 @@ class BpnXmlSyncCommandTest extends TestCase $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); } - - public function testContingentTransferFailureDoesNotFailSuccessfulTravelSync(): void - { - $newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n"; - $olderTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n"; - - $this->xmlSource->method('read')->willReturn($newerTimestamp); - $this->xmlExport->method('fileExists')->willReturn(true); - $this->xmlExport->method('read')->willReturn($olderTimestamp); - $this->xmlSource->method('listContents')->willReturn(new DirectoryListing([])); - $this->xmlExport->method('listContents')->willReturn(new DirectoryListing([])); - - $this->xmlSourceContingents->method('read')->willReturn($newerTimestamp); - $this->xmlExportContingents->method('fileExists')->willReturn(true); - $this->xmlExportContingents->method('read')->willReturn($olderTimestamp); - $this->xmlSourceContingents->method('listContents') - ->willThrowException(UnableToListContents::atLocation('.', false, new \RuntimeException('SFTP unavailable'))); - - $fileMap = [ - 101 => ['hotels' => ['H1' => null]], - ]; - $this->travelLoader->expects($this->once()) - ->method('generateFilesMap') - ->willReturn($fileMap); - - $this->travelDataService->expects($this->once()) - ->method('syncSnapshotsFromXml') - ->with($fileMap, $this->isInstanceOf(\Closure::class)) - ->willReturn(['processed' => 1, 'failed' => 0]); - - $this->travelSnapshotService->expects($this->once()) - ->method('purgeOrphanedFutureSnapshots') - ->with([101]) - ->willReturn(0); - - $this->cache->expects($this->once()) - ->method('invalidateTags') - ->with(['xml-sync']); - - $tester = new CommandTester($this->command); - $tester->execute([]); - - $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); - $this->assertStringContainsString('[contingents] Sync failed', $tester->getDisplay()); - $this->assertMatchesRegularExpression('/failed datasets:\s+contingents/', $tester->getDisplay()); - } - - public function testTravelTransferFailureDoesNotPreventContingentsSync(): void - { - $newerTimestamp = "24.03.2026 12:00:00\nExport\n3 Dateien\n"; - $olderTimestamp = "24.03.2026 10:00:00\nExport\n3 Dateien\n"; - - $this->xmlSource->method('read')->willReturn($newerTimestamp); - $this->xmlExport->method('fileExists')->willReturn(true); - $this->xmlExport->method('read')->willReturn($olderTimestamp); - $this->xmlSource->method('listContents') - ->willThrowException(UnableToListContents::atLocation('.', false, new \RuntimeException('SFTP unavailable'))); - - $this->xmlSourceContingents->method('read')->willReturn($newerTimestamp); - $this->xmlExportContingents->method('fileExists')->willReturn(true); - $this->xmlExportContingents->method('read')->willReturn($olderTimestamp); - $this->xmlSourceContingents->method('listContents')->willReturn(new DirectoryListing([])); - $this->xmlExportContingents->method('listContents')->willReturn(new DirectoryListing([])); - - $this->travelLoader->expects($this->never())->method('generateFilesMap'); - $this->travelDataService->expects($this->never())->method('syncSnapshotsFromXml'); - $this->travelSnapshotService->expects($this->never())->method('purgeOrphanedFutureSnapshots'); - - $this->cache->expects($this->once()) - ->method('invalidateTags') - ->with(['xml-sync']); - - $tester = new CommandTester($this->command); - $tester->execute([]); - - $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); - $this->assertStringContainsString('[travel] Sync failed', $tester->getDisplay()); - $this->assertStringContainsString('[contingents] Synced 0 files, deleted 0', $tester->getDisplay()); - $this->assertStringContainsString('datasets: travel', $tester->getDisplay()); - } } diff --git a/tests/Controller/Api/ContingentControllerTest.php b/tests/Controller/Api/ContingentControllerTest.php deleted file mode 100644 index 91e866e..0000000 --- a/tests/Controller/Api/ContingentControllerTest.php +++ /dev/null @@ -1,188 +0,0 @@ -createMock(ContingentDataService::class); - $travelDataProvider = $this->createMock(TravelDataProvider::class); - - $travelDataProvider - ->expects($this->once()) - ->method('mapHotelCodeToId') - ->with('SBW-HOTEL') - ->willReturn(157047); - - $travelDataProvider - ->expects($this->once()) - ->method('mapDateCodeToId') - ->with('SBW131225') - ->willReturn(11606); - - $contingentDataService - ->expects($this->once()) - ->method('getAvailableContingents') - ->with(157047, 11606) - ->willReturn([]); - - $controller = $this->createController($contingentDataService, $travelDataProvider); - $request = Request::create('/api/contingents', 'GET', [ - 'hotelRef' => 'SBW-HOTEL', - 'dateRef' => 'sbw-13/12/25', - ]); - - $response = $controller->byDate($request); - - $this->assertSame(200, $response->getStatusCode()); - } - - public function testByDateQueryRequiresDateReference(): void - { - $contingentDataService = $this->createMock(ContingentDataService::class); - $travelDataProvider = $this->createMock(TravelDataProvider::class); - - $controller = $this->createController($contingentDataService, $travelDataProvider); - $request = Request::create('/api/contingents', 'GET', [ - 'hotelRef' => '157047', - ]); - - $response = $controller->byDate($request); - $payload = json_decode((string) $response->getContent(), true); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertSame('dateRef is required', $payload['error']); - } - - public function testByDateQueryReturnsNotFoundForUnknownDateReference(): void - { - $contingentDataService = $this->createMock(ContingentDataService::class); - $travelDataProvider = $this->createMock(TravelDataProvider::class); - - $travelDataProvider - ->expects($this->once()) - ->method('mapHotelCodeToId') - ->with('SBW-HOTEL') - ->willReturn(157047); - - $travelDataProvider - ->expects($this->once()) - ->method('mapDateCodeToId') - ->with('UNKNOWN') - ->willReturn(null); - - $contingentDataService - ->expects($this->never()) - ->method('getAvailableContingents'); - - $controller = $this->createController($contingentDataService, $travelDataProvider); - $request = Request::create('/api/contingents', 'GET', [ - 'hotelRef' => 'SBW-HOTEL', - 'dateRef' => 'unknown', - ]); - - $response = $controller->byDate($request); - $payload = json_decode((string) $response->getContent(), true); - - $this->assertSame(404, $response->getStatusCode()); - $this->assertSame('Not found', $payload['message']); - } - - public function testRoomsQueryRequiresDateRange(): void - { - $contingentDataService = $this->createMock(ContingentDataService::class); - $travelDataProvider = $this->createMock(TravelDataProvider::class); - - $controller = $this->createController($contingentDataService, $travelDataProvider); - $request = Request::create('/api/contingents/rooms', 'GET', [ - 'hotelRef' => '157047', - 'dateRef' => '11606', - ]); - - $response = $controller->rooms($request); - $payload = json_decode((string) $response->getContent(), true); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertSame('dateFrom and dateTo are required', $payload['error']); - } - - public function testByDateQueryUsesNumericReferencesAsIdentifiers(): void - { - $contingentDataService = $this->createMock(ContingentDataService::class); - $travelDataProvider = $this->createMock(TravelDataProvider::class); - - $travelDataProvider - ->expects($this->never()) - ->method('mapHotelCodeToId'); - - $travelDataProvider - ->expects($this->never()) - ->method('mapDateCodeToId'); - - $contingentDataService - ->expects($this->once()) - ->method('getAvailableContingents') - ->with(157047, 11606) - ->willReturn([]); - - $controller = $this->createController($contingentDataService, $travelDataProvider); - $request = Request::create('/api/contingents', 'GET', [ - 'hotelRef' => '157047', - 'dateRef' => '11606', - ]); - $response = $controller->byDate($request); - - $this->assertSame(200, $response->getStatusCode()); - } - - public function testRoomsQueryForwardsOptionalMyEpUrl(): void - { - $contingentDataService = $this->createMock(ContingentDataService::class); - $travelDataProvider = $this->createMock(TravelDataProvider::class); - - $contingentDataService - ->expects($this->once()) - ->method('getAvailableRooms') - ->with('2026-03-01', '2026-03-08', 157047, 11606, 'https://my.ep-reisen.de') - ->willReturn([]); - - $controller = $this->createController($contingentDataService, $travelDataProvider); - $request = Request::create('/api/contingents/rooms', 'GET', [ - 'hotelRef' => '157047', - 'dateRef' => '11606', - 'dateFrom' => '2026-03-01', - 'dateTo' => '2026-03-08', - 'my_ep_url' => 'https://my.ep-reisen.de', - ]); - - $response = $controller->rooms($request); - - $this->assertSame(200, $response->getStatusCode()); - } - - private function createController( - ContingentDataService $contingentDataService, - TravelDataProvider $travelDataProvider, - ): ContingentController { - $controller = new ContingentController($contingentDataService, $travelDataProvider); - - $container = new Container(); - $container->set('serializer', new Serializer([new ObjectNormalizer()], [new JsonEncoder()])); - $controller->setContainer($container); - - return $controller; - } -} diff --git a/tests/Service/ContingentDataServiceTest.php b/tests/Service/ContingentDataServiceTest.php deleted file mode 100644 index 2d201c2..0000000 --- a/tests/Service/ContingentDataServiceTest.php +++ /dev/null @@ -1,163 +0,0 @@ -contingentLoader = $this->createMock(ContingentLoader::class); - $this->travelLoader = $this->createMock(TravelLoader::class); - - $this->service = new ContingentDataService( - $this->contingentLoader, - $this->travelLoader, - new BookingUrlUtility('https://my.ep-reisen.de'), - ); - } - - public function testGetAvailableContingentsAggregatesAndAppliesBelKalOverride(): void - { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2026-02-01'); - $travel->dateTo = new \DateTimeImmutable('2026-02-03'); - - $this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel); - $this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([ - 'rows' => [ - [ - 'date' => new \DateTimeImmutable('2026-02-01'), - 'isControlRoom' => false, - 'available' => 4, - 'pax' => 8, - 'minNights' => 3, - 'status' => 'OK', - ], - [ - 'date' => new \DateTimeImmutable('2026-02-01'), - 'isControlRoom' => false, - 'available' => 2, - 'pax' => 4, - 'minNights' => 2, - 'status' => 'OK', - ], - [ - 'date' => new \DateTimeImmutable('2026-02-01'), - 'isControlRoom' => true, - 'available' => 0, - 'pax' => 0, - 'minNights' => null, - 'status' => 'BLOCKED', - ], - ], - ]); - - $result = $this->service->getAvailableContingents(10, 123); - - $this->assertArrayHasKey('2026-02-01', $result); - $summary = $result['2026-02-01']; - $this->assertSame('2026-02-01', $summary->date); - $this->assertSame(2, $summary->minNights); - $this->assertSame(12, $summary->capacity); - $this->assertSame(0, $summary->total); // BelKal override - } - - public function testGetAvailableRoomsRejectsRangeOutsideTravelPeriod(): void - { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2026-02-10'); - $travel->dateTo = new \DateTimeImmutable('2026-02-20'); - - $this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Requested range must be within the travel date range'); - - $this->service->getAvailableRooms('2026-02-01', '2026-02-05', 10, 123); - } - - public function testGetAvailableRoomsCalculatesPriceAndAppliesBelKalStatusOverride(): void - { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2026-02-01'); - $travel->dateTo = new \DateTimeImmutable('2026-02-10'); - - $this->travelLoader->method('loadById')->with(123, 10)->willReturn($travel); - $this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([ - 'rows' => [ - [ - 'date' => new \DateTimeImmutable('2026-02-03'), - 'isControlRoom' => true, - 'status' => 'ON_REQUEST', - ], - [ - 'date' => new \DateTimeImmutable('2026-02-03'), - 'isControlRoom' => false, - 'roomCode' => 'DZ', - 'roomLabel' => 'Double', - 'pax' => 4, - 'available' => 2, - 'status' => 'OK', - 'minPrice' => 100.0, - 'minNights' => 2, - 'additionalNightMinPrice' => 30.0, - 'additionalNightMinNights' => 1, - ], - ], - ]); - - $result = $this->service->getAvailableRooms('2026-02-03', '2026-02-06', 10, 123); - - $this->assertCount(1, $result); - $room = $result[0]; - $this->assertSame('2026-02-03', $room->date); - $this->assertSame('ON_REQUEST', $room->status); - $this->assertSame(0, $room->available); // overridden - $this->assertSame(130.0, $room->priceForSelection); // 100 + (3-2)*30 - $this->assertSame('https://my.ep-reisen.de/bookings/create?date_id=123&hotel_id=10', $room->bookingUrl); - } - - public function testGetCalendarEventsUsesWorstStatusAndExcludesControlRoomFromSums(): void - { - $this->contingentLoader->method('loadByHotelId')->with(10)->willReturn([ - 'rows' => [ - [ - 'date' => new \DateTimeImmutable('2026-02-03'), - 'isControlRoom' => false, - 'status' => 'OK', - 'pax' => 6, - 'available' => 2, - ], - [ - 'date' => new \DateTimeImmutable('2026-02-03'), - 'isControlRoom' => true, - 'status' => 'BLOCKED', - 'pax' => 0, - 'available' => 0, - ], - ], - ]); - - $events = $this->service->getCalendarEvents(10, '2026-02-01', '2026-02-10'); - - $this->assertCount(1, $events); - $event = $events[0]; - $this->assertSame('2026-02-03', $event->date); - $this->assertSame('BLOCKED', $event->status); - $this->assertSame(6, $event->pax); - $this->assertSame(2, $event->available); - } -}