From a07edfe3ecc1209e1121a97c7d9512baa4fd44ed Mon Sep 17 00:00:00 2001
From: Jonas Linter <{email_address}>
Date: Thu, 27 Nov 2025 18:57:45 +0100
Subject: [PATCH] Free rooms first implementation
---
...c_add_inventory_and_availability_tables.py | 108 ++++
src/alpine_bits_python/alpinebits_server.py | 8 +
src/alpine_bits_python/db.py | 65 ++
src/alpine_bits_python/free_rooms_action.py | 600 ++++++++++++++++++
tests/test_api_freerooms.py | 215 +++++++
tests/test_free_rooms_action.py | 367 +++++++++++
6 files changed, 1363 insertions(+)
create mode 100644 alembic/versions/2025_11_27_1200-b2cfe2d3aabc_add_inventory_and_availability_tables.py
create mode 100644 src/alpine_bits_python/free_rooms_action.py
create mode 100644 tests/test_api_freerooms.py
create mode 100644 tests/test_free_rooms_action.py
diff --git a/alembic/versions/2025_11_27_1200-b2cfe2d3aabc_add_inventory_and_availability_tables.py b/alembic/versions/2025_11_27_1200-b2cfe2d3aabc_add_inventory_and_availability_tables.py
new file mode 100644
index 0000000..7647116
--- /dev/null
+++ b/alembic/versions/2025_11_27_1200-b2cfe2d3aabc_add_inventory_and_availability_tables.py
@@ -0,0 +1,108 @@
+"""Add hotel inventory and room availability tables
+
+Revision ID: b2cfe2d3aabc
+Revises: e7ee03d8f430
+Create Date: 2025-11-27 12:00:00.000000
+
+"""
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "b2cfe2d3aabc"
+down_revision: str | Sequence[str] | None = "e7ee03d8f430"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema with inventory and availability tables."""
+ op.create_table(
+ "hotel_inventory",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("hotel_id", sa.String(length=50), nullable=False),
+ sa.Column("inv_type_code", sa.String(length=8), nullable=False),
+ sa.Column("inv_code", sa.String(length=16), nullable=True),
+ sa.Column("room_name", sa.String(length=200), nullable=True),
+ sa.Column("max_occupancy", sa.Integer(), nullable=True),
+ sa.Column("source", sa.String(length=20), nullable=False),
+ sa.Column("first_seen", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("last_updated", sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(["hotel_id"], ["hotels.hotel_id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ op.f("ix_hotel_inventory_hotel_id"),
+ "hotel_inventory",
+ ["hotel_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_hotel_inventory_inv_type_code"),
+ "hotel_inventory",
+ ["inv_type_code"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_hotel_inventory_inv_code"),
+ "hotel_inventory",
+ ["inv_code"],
+ unique=False,
+ )
+ op.create_index(
+ "uq_hotel_inventory_unique_key",
+ "hotel_inventory",
+ ["hotel_id", "inv_type_code", sa.text("COALESCE(inv_code, '')")],
+ unique=True,
+ )
+
+ op.create_table(
+ "room_availability",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("inventory_id", sa.Integer(), nullable=False),
+ sa.Column("date", sa.Date(), nullable=False),
+ sa.Column("count_type_2", sa.Integer(), nullable=True),
+ sa.Column("count_type_6", sa.Integer(), nullable=True),
+ sa.Column("count_type_9", sa.Integer(), nullable=True),
+ sa.Column("is_closing_season", sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column("last_updated", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("update_type", sa.String(length=20), nullable=False),
+ sa.ForeignKeyConstraint(["inventory_id"], ["hotel_inventory.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("inventory_id", "date", name="uq_room_availability_unique_key"),
+ )
+ op.create_index(
+ op.f("ix_room_availability_inventory_id"),
+ "room_availability",
+ ["inventory_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_room_availability_date"),
+ "room_availability",
+ ["date"],
+ unique=False,
+ )
+ op.create_index(
+ "idx_room_availability_inventory_date",
+ "room_availability",
+ ["inventory_id", "date"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema by removing availability tables."""
+ op.drop_index("idx_room_availability_inventory_date", table_name="room_availability")
+ op.drop_index(op.f("ix_room_availability_date"), table_name="room_availability")
+ op.drop_index(op.f("ix_room_availability_inventory_id"), table_name="room_availability")
+ op.drop_table("room_availability")
+
+ op.drop_index("uq_hotel_inventory_unique_key", table_name="hotel_inventory")
+ op.drop_index(op.f("ix_hotel_inventory_inv_code"), table_name="hotel_inventory")
+ op.drop_index(op.f("ix_hotel_inventory_inv_type_code"), table_name="hotel_inventory")
+ op.drop_index(op.f("ix_hotel_inventory_hotel_id"), table_name="hotel_inventory")
+ op.drop_table("hotel_inventory")
diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py
index 7837e87..677e695 100644
--- a/src/alpine_bits_python/alpinebits_server.py
+++ b/src/alpine_bits_python/alpinebits_server.py
@@ -86,6 +86,10 @@ class AlpineBitsActionName(Enum):
"action_OTA_HotelRatePlan_BaseRates",
"OTA_HotelRatePlan:BaseRates",
)
+ OTA_HOTEL_INV_COUNT_NOTIF_FREE_ROOMS = (
+ "action_OTA_HotelInvCountNotif",
+ "OTA_HotelInvCountNotif:FreeRooms",
+ )
def __init__(self, capability_name: str, request_name: str):
self.capability_name = capability_name
@@ -819,3 +823,7 @@ class AlpineBitsServer:
return False
return True
+
+
+# Ensure FreeRoomsAction is registered with ServerCapabilities discovery
+from .free_rooms_action import FreeRoomsAction # noqa: E402,F401
diff --git a/src/alpine_bits_python/db.py b/src/alpine_bits_python/db.py
index 41f859f..c71fecc 100644
--- a/src/alpine_bits_python/db.py
+++ b/src/alpine_bits_python/db.py
@@ -18,6 +18,8 @@ from sqlalchemy import (
Index,
Integer,
String,
+ UniqueConstraint,
+ func,
)
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import (
@@ -679,6 +681,66 @@ class ConversionRoom(Base):
conversion = relationship("Conversion", back_populates="conversion_rooms")
+class HotelInventory(Base):
+ """Room and category definitions synchronized via AlpineBits."""
+
+ __tablename__ = "hotel_inventory"
+
+ id = Column(Integer, primary_key=True)
+ hotel_id = Column(
+ String(50), ForeignKey("hotels.hotel_id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ inv_type_code = Column(String(8), nullable=False, index=True)
+ inv_code = Column(String(16), nullable=True, index=True)
+ room_name = Column(String(200), nullable=True)
+ max_occupancy = Column(Integer, nullable=True)
+ source = Column(String(20), nullable=False)
+ first_seen = Column(DateTime(timezone=True), nullable=False)
+ last_updated = Column(DateTime(timezone=True), nullable=False)
+
+ hotel = relationship("Hotel", back_populates="inventory_items")
+ availability = relationship(
+ "RoomAvailability",
+ back_populates="inventory_item",
+ cascade="all, delete-orphan",
+ passive_deletes=True,
+ )
+
+ __table_args__ = (
+ Index(
+ "uq_hotel_inventory_unique_key",
+ "hotel_id",
+ "inv_type_code",
+ func.coalesce(inv_code, ""),
+ unique=True,
+ ),
+ )
+
+
+class RoomAvailability(Base):
+ """Daily availability counts for inventory items."""
+
+ __tablename__ = "room_availability"
+
+ id = Column(Integer, primary_key=True)
+ inventory_id = Column(
+ Integer, ForeignKey("hotel_inventory.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ date = Column(Date, nullable=False, index=True)
+ count_type_2 = Column(Integer, nullable=True)
+ count_type_6 = Column(Integer, nullable=True)
+ count_type_9 = Column(Integer, nullable=True)
+ is_closing_season = Column(Boolean, nullable=False, default=False)
+ last_updated = Column(DateTime(timezone=True), nullable=False)
+ update_type = Column(String(20), nullable=False)
+
+ inventory_item = relationship("HotelInventory", back_populates="availability")
+
+ __table_args__ = (
+ UniqueConstraint("inventory_id", "date", name="uq_room_availability_unique_key"),
+ )
+
+
class Hotel(Base):
"""Hotel configuration (migrated from alpine_bits_auth in config.yaml)."""
@@ -710,6 +772,9 @@ class Hotel(Base):
# Relationships
webhook_endpoints = relationship("WebhookEndpoint", back_populates="hotel")
+ inventory_items = relationship(
+ "HotelInventory", back_populates="hotel", cascade="all, delete-orphan"
+ )
class WebhookEndpoint(Base):
diff --git a/src/alpine_bits_python/free_rooms_action.py b/src/alpine_bits_python/free_rooms_action.py
new file mode 100644
index 0000000..840f7b9
--- /dev/null
+++ b/src/alpine_bits_python/free_rooms_action.py
@@ -0,0 +1,600 @@
+"""Action handler for OTA_HotelInvCountNotif:FreeRooms."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, date, datetime, timedelta
+from typing import Any
+
+from sqlalchemy import delete, select
+from sqlalchemy.dialects.postgresql import insert as pg_insert
+from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+from sqlalchemy.ext.asyncio import AsyncSession
+from xsdata.formats.dataclass.serializers.config import SerializerConfig
+from xsdata_pydantic.bindings import XmlParser, XmlSerializer
+
+from .alpinebits_server import (
+ AlpineBitsAction,
+ AlpineBitsActionName,
+ AlpineBitsClientInfo,
+ AlpineBitsResponse,
+ Version,
+ validate_hotel_authentication,
+)
+from .const import HttpStatusCode
+from .db import Hotel, HotelInventory, RoomAvailability
+from .generated import (
+ ErrorType,
+ InvCountCountType,
+ OtaHotelInvCountNotifRq,
+ OtaHotelInvCountNotifRs,
+ UniqueIdInstance,
+)
+from .logging_config import get_logger
+
+_LOGGER = get_logger(__name__)
+
+SUPPORTED_CAPABILITIES = [
+ "OTA_HotelInvCountNotif_accept_rooms",
+ "OTA_HotelInvCountNotif_accept_categories",
+ "OTA_HotelInvCountNotif_accept_deltas",
+ "OTA_HotelInvCountNotif_accept_complete_set",
+ "OTA_HotelInvCountNotif_accept_out_of_order",
+ "OTA_HotelInvCountNotif_accept_out_of_market",
+ "OTA_HotelInvCountNotif_accept_closing_seasons",
+]
+
+CLOSING_SEASON_TYPE = "__CLOSE" # <= 8 chars per spec
+SOURCE_FREEROOMS = "FreeRooms"
+
+COUNT_TYPE_MAP = {
+ InvCountCountType.VALUE_2: "count_type_2",
+ InvCountCountType.VALUE_6: "count_type_6",
+ InvCountCountType.VALUE_9: "count_type_9",
+}
+
+
+@dataclass
+class FreeRoomsProcessingError(Exception):
+ """Custom exception that carries HTTP and OTA error metadata."""
+
+ message: str
+ status_code: HttpStatusCode = HttpStatusCode.BAD_REQUEST
+ error_type: ErrorType = ErrorType.VALUE_13
+ code: str = "450"
+
+ def __str__(self) -> str:
+ return self.message
+
+
+class FreeRoomsAction(AlpineBitsAction):
+ """Handler for OTA_HotelInvCountNotif:FreeRooms requests."""
+
+ def __init__(self, config: dict | None = None):
+ self.name = AlpineBitsActionName.OTA_HOTEL_INV_COUNT_NOTIF_FREE_ROOMS
+ self.version = [Version.V2024_10, Version.V2022_10]
+ self.config = config or {}
+ self.supports = SUPPORTED_CAPABILITIES
+
+ self._parser = XmlParser()
+ self._serializer = XmlSerializer(
+ config=SerializerConfig(
+ pretty_print=True,
+ xml_declaration=True,
+ encoding="UTF-8",
+ )
+ )
+
+ async def handle(
+ self,
+ action: str,
+ request_xml: str,
+ version: Version,
+ client_info: AlpineBitsClientInfo,
+ dbsession: AsyncSession | None = None,
+ server_capabilities=None,
+ ) -> AlpineBitsResponse:
+ """Process FreeRooms inventory updates."""
+ try:
+ self._validate_action_name(action)
+
+ if request_xml is None:
+ raise FreeRoomsProcessingError("Missing request payload")
+
+ if dbsession is None:
+ raise FreeRoomsProcessingError(
+ "Database session unavailable",
+ HttpStatusCode.INTERNAL_SERVER_ERROR,
+ )
+
+ try:
+ request = self._parser.from_string(request_xml, OtaHotelInvCountNotifRq)
+ except Exception as exc: # pragma: no cover - serialization already tested upstream
+ _LOGGER.exception("Failed to parse FreeRooms request: %s", exc)
+ raise FreeRoomsProcessingError("Invalid XML payload") from exc
+
+ hotel_code = request.inventories.hotel_code if request.inventories else None
+ if not hotel_code:
+ raise FreeRoomsProcessingError("HotelCode attribute is required")
+
+ if not client_info or not client_info.username or not client_info.password:
+ raise FreeRoomsProcessingError(
+ "Missing authentication context",
+ HttpStatusCode.UNAUTHORIZED,
+ error_type=ErrorType.VALUE_11,
+ code="401",
+ )
+
+ if not validate_hotel_authentication(
+ client_info.username,
+ client_info.password,
+ hotel_code,
+ self.config,
+ ):
+ raise FreeRoomsProcessingError(
+ f"Unauthorized FreeRooms notification for hotel {hotel_code}",
+ HttpStatusCode.UNAUTHORIZED,
+ error_type=ErrorType.VALUE_11,
+ code="401",
+ )
+
+ hotel = await self._fetch_hotel(dbsession, hotel_code)
+ if hotel is None:
+ raise FreeRoomsProcessingError(
+ f"Hotel {hotel_code} is not provisioned on this server"
+ )
+
+ is_complete_set = (
+ request.unique_id is not None
+ and request.unique_id.instance == UniqueIdInstance.COMPLETE_SET
+ )
+ update_type = "CompleteSet" if is_complete_set else "Delta"
+
+ inventory_cache: dict[tuple[str, str | None], HotelInventory] = {}
+
+ try:
+ if is_complete_set:
+ await self._process_complete_set(
+ dbsession, hotel, request, update_type, inventory_cache
+ )
+ else:
+ await self._process_delta(
+ dbsession, hotel, request, update_type, inventory_cache
+ )
+ await dbsession.commit()
+ except FreeRoomsProcessingError:
+ await dbsession.rollback()
+ raise
+ except Exception as exc: # pragma: no cover - defensive
+ await dbsession.rollback()
+ _LOGGER.exception("Unexpected FreeRooms failure: %s", exc)
+ return self._error_response(
+ "Internal server error while processing FreeRooms notification",
+ HttpStatusCode.INTERNAL_SERVER_ERROR,
+ )
+
+ _LOGGER.info(
+ "Processed FreeRooms %s update for hotel %s (%d inventory items)",
+ update_type,
+ hotel_code,
+ len(request.inventories.inventory),
+ )
+ return self._success_response()
+ except FreeRoomsProcessingError as exc:
+ return self._error_response(
+ exc.message,
+ exc.status_code,
+ error_type=exc.error_type,
+ code=exc.code,
+ )
+
+ def _validate_action_name(self, action: str) -> None:
+ expected = self.name.value[1]
+ if (action or "").strip() != expected:
+ raise FreeRoomsProcessingError(
+ f"Invalid action {action}, expected {expected}",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
+ async def _fetch_hotel(self, session: AsyncSession, hotel_code: str) -> Hotel | None:
+ stmt = select(Hotel).where(Hotel.hotel_id == hotel_code, Hotel.is_active.is_(True))
+ result = await session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def _process_complete_set(
+ self,
+ session: AsyncSession,
+ hotel: Hotel,
+ request: OtaHotelInvCountNotifRq,
+ update_type: str,
+ inventory_cache: dict[tuple[str, str | None], HotelInventory],
+ ) -> None:
+ await self._delete_existing_availability(session, hotel.hotel_id)
+ await self._process_inventories(
+ session, hotel, request, update_type, inventory_cache, enforce_closing_order=True
+ )
+
+ async def _process_delta(
+ self,
+ session: AsyncSession,
+ hotel: Hotel,
+ request: OtaHotelInvCountNotifRq,
+ update_type: str,
+ inventory_cache: dict[tuple[str, str | None], HotelInventory],
+ ) -> None:
+ await self._process_inventories(
+ session, hotel, request, update_type, inventory_cache, enforce_closing_order=False
+ )
+
+ async def _delete_existing_availability(
+ self,
+ session: AsyncSession,
+ hotel_id: str,
+ ) -> None:
+ subquery = select(HotelInventory.id).where(HotelInventory.hotel_id == hotel_id)
+ await session.execute(
+ delete(RoomAvailability).where(RoomAvailability.inventory_id.in_(subquery))
+ )
+
+ async def _process_inventories(
+ self,
+ session: AsyncSession,
+ hotel: Hotel,
+ request: OtaHotelInvCountNotifRq,
+ update_type: str,
+ inventory_cache: dict[tuple[str, str | None], HotelInventory],
+ enforce_closing_order: bool,
+ ) -> None:
+ inventories = request.inventories.inventory if request.inventories else []
+ if not inventories:
+ raise FreeRoomsProcessingError(
+ "Request must include at least one Inventory block",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
+ rows_to_upsert: list[dict[str, Any]] = []
+ now = datetime.now(UTC)
+ encountered_standard = False
+
+ for inventory in inventories:
+ sac = inventory.status_application_control
+ if sac is None:
+ raise FreeRoomsProcessingError(
+ "StatusApplicationControl element is required for each Inventory",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
+ is_closing = self._is_closing_season(sac)
+ if is_closing:
+ if inventory.inv_counts is not None:
+ raise FreeRoomsProcessingError(
+ "Closing seasons cannot contain InvCounts data",
+ HttpStatusCode.BAD_REQUEST,
+ )
+ if update_type != "CompleteSet":
+ raise FreeRoomsProcessingError(
+ "Closing seasons are only allowed on CompleteSet updates",
+ HttpStatusCode.BAD_REQUEST,
+ )
+ if enforce_closing_order and encountered_standard:
+ raise FreeRoomsProcessingError(
+ "Closing seasons must appear before other inventory entries",
+ HttpStatusCode.BAD_REQUEST,
+ )
+ rows_to_upsert.extend(
+ await self._process_closing_season(
+ session, hotel, sac, update_type, now, inventory_cache
+ )
+ )
+ continue
+
+ encountered_standard = True
+ rows_to_upsert.extend(
+ await self._process_inventory_item(
+ session,
+ hotel,
+ sac,
+ inventory.inv_counts,
+ update_type,
+ now,
+ inventory_cache,
+ )
+ )
+
+ await self._upsert_availability_rows(session, rows_to_upsert)
+
+ async def _process_closing_season(
+ self,
+ session: AsyncSession,
+ hotel: Hotel,
+ sac: OtaHotelInvCountNotifRq.Inventories.Inventory.StatusApplicationControl,
+ update_type: str,
+ timestamp: datetime,
+ inventory_cache: dict[tuple[str, str | None], HotelInventory],
+ ) -> list[dict[str, Any]]:
+ if sac.inv_type_code or sac.inv_code:
+ raise FreeRoomsProcessingError(
+ "Closing season entries cannot specify InvTypeCode or InvCode",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
+ start_date, end_date = self._parse_date_range(sac.start, sac.end)
+ inventory_item = await self._ensure_inventory_item(
+ session,
+ hotel.hotel_id,
+ CLOSING_SEASON_TYPE,
+ None,
+ timestamp,
+ inventory_cache,
+ )
+
+ base_payload = {
+ "inventory_id": inventory_item.id,
+ "count_type_2": None,
+ "count_type_6": None,
+ "count_type_9": None,
+ "is_closing_season": True,
+ "last_updated": timestamp,
+ "update_type": update_type,
+ }
+
+ rows = []
+ for day in self._iter_days(start_date, end_date):
+ payload = dict(base_payload)
+ payload["date"] = day
+ rows.append(payload)
+ return rows
+
+ async def _process_inventory_item(
+ self,
+ session: AsyncSession,
+ hotel: Hotel,
+ sac: OtaHotelInvCountNotifRq.Inventories.Inventory.StatusApplicationControl,
+ inv_counts: (
+ OtaHotelInvCountNotifRq.Inventories.Inventory.InvCounts | None
+ ),
+ update_type: str,
+ timestamp: datetime,
+ inventory_cache: dict[tuple[str, str | None], HotelInventory],
+ ) -> list[dict[str, Any]]:
+ inv_type_code = (sac.inv_type_code or "").strip()
+ if not inv_type_code:
+ raise FreeRoomsProcessingError(
+ "InvTypeCode is required unless AllInvCode=\"true\"",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
+ inv_code = sac.inv_code.strip() if sac.inv_code else None
+ start_date, end_date = self._parse_date_range(sac.start, sac.end)
+
+ counts = self._extract_counts(inv_counts)
+ base_counts = {
+ "count_type_2": counts.get("count_type_2"),
+ "count_type_6": counts.get("count_type_6"),
+ "count_type_9": counts.get("count_type_9"),
+ }
+
+ inventory_item = await self._ensure_inventory_item(
+ session,
+ hotel.hotel_id,
+ inv_type_code,
+ inv_code,
+ timestamp,
+ inventory_cache,
+ )
+
+ base_payload = {
+ "inventory_id": inventory_item.id,
+ "is_closing_season": False,
+ "last_updated": timestamp,
+ "update_type": update_type,
+ **base_counts,
+ }
+
+ rows = []
+ for day in self._iter_days(start_date, end_date):
+ payload = dict(base_payload)
+ payload["date"] = day
+ rows.append(payload)
+ return rows
+
+ def _parse_date_range(self, start_str: str, end_str: str) -> tuple[date, date]:
+ try:
+ start_date = date.fromisoformat(start_str)
+ end_date = date.fromisoformat(end_str)
+ except ValueError as exc:
+ raise FreeRoomsProcessingError(
+ f"Invalid date format: {exc!s}",
+ HttpStatusCode.BAD_REQUEST,
+ ) from exc
+
+ if end_date < start_date:
+ raise FreeRoomsProcessingError(
+ "StatusApplicationControl End date cannot be before Start date",
+ HttpStatusCode.BAD_REQUEST,
+ )
+ return start_date, end_date
+
+ def _iter_days(self, start_date: date, end_date: date):
+ current = start_date
+ while current <= end_date:
+ yield current
+ current += timedelta(days=1)
+
+ def _is_closing_season(
+ self,
+ sac: OtaHotelInvCountNotifRq.Inventories.Inventory.StatusApplicationControl,
+ ) -> bool:
+ return (sac.all_inv_code or "").strip().lower() == "true"
+
+ def _extract_counts(
+ self,
+ inv_counts: OtaHotelInvCountNotifRq.Inventories.Inventory.InvCounts | None,
+ ) -> dict[str, int | None]:
+ if inv_counts is None or not inv_counts.inv_count:
+ return {}
+
+ parsed: dict[str, int] = {}
+ for count in inv_counts.inv_count:
+ column_name = COUNT_TYPE_MAP.get(count.count_type)
+ if column_name is None:
+ raise FreeRoomsProcessingError(
+ f"Unsupported CountType {count.count_type}",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
+ if column_name in parsed:
+ raise FreeRoomsProcessingError(
+ f"Duplicate CountType {count.count_type.value} detected",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
+ try:
+ value = int(count.count)
+ except ValueError as exc:
+ raise FreeRoomsProcessingError(
+ f"Invalid Count value '{count.count}'",
+ HttpStatusCode.BAD_REQUEST,
+ ) from exc
+
+ if value < 0:
+ raise FreeRoomsProcessingError(
+ "Count values must be non-negative",
+ HttpStatusCode.BAD_REQUEST,
+ )
+
+ parsed[column_name] = value
+
+ return parsed
+
+ async def _ensure_inventory_item(
+ self,
+ session: AsyncSession,
+ hotel_id: str,
+ inv_type_code: str,
+ inv_code: str | None,
+ timestamp: datetime,
+ cache: dict[tuple[str, str | None], HotelInventory],
+ ) -> HotelInventory:
+ cache_key = (inv_type_code, inv_code)
+ if cache_key in cache:
+ return cache[cache_key]
+
+ filters = [
+ HotelInventory.hotel_id == hotel_id,
+ HotelInventory.inv_type_code == inv_type_code,
+ ]
+ if inv_code is None:
+ filters.append(HotelInventory.inv_code.is_(None))
+ else:
+ filters.append(HotelInventory.inv_code == inv_code)
+
+ stmt = select(HotelInventory).where(*filters)
+ result = await session.execute(stmt)
+ inventory_item = result.scalar_one_or_none()
+
+ if inventory_item:
+ inventory_item.last_updated = timestamp
+ else:
+ inventory_item = HotelInventory(
+ hotel_id=hotel_id,
+ inv_type_code=inv_type_code,
+ inv_code=inv_code,
+ source=SOURCE_FREEROOMS,
+ first_seen=timestamp,
+ last_updated=timestamp,
+ )
+ session.add(inventory_item)
+ await session.flush()
+
+ cache[cache_key] = inventory_item
+ return inventory_item
+
+ async def _upsert_availability_rows(
+ self,
+ session: AsyncSession,
+ rows: list[dict[str, Any]],
+ ) -> None:
+ if not rows:
+ return
+
+ bind = session.get_bind()
+ dialect_name = bind.dialect.name if bind else ""
+ table = RoomAvailability.__table__
+
+ if dialect_name == "postgresql":
+ stmt = pg_insert(table).values(rows)
+ stmt = stmt.on_conflict_do_update(
+ index_elements=["inventory_id", "date"],
+ set_=self._build_upsert_set(stmt),
+ )
+ await session.execute(stmt)
+ return
+
+ if dialect_name == "sqlite":
+ stmt = sqlite_insert(table).values(rows)
+ stmt = stmt.on_conflict_do_update(
+ index_elements=["inventory_id", "date"],
+ set_=self._build_upsert_set(stmt),
+ )
+ await session.execute(stmt)
+ return
+
+ await self._upsert_with_fallback(session, rows)
+
+ def _build_upsert_set(self, stmt):
+ return {
+ "count_type_2": stmt.excluded.count_type_2,
+ "count_type_6": stmt.excluded.count_type_6,
+ "count_type_9": stmt.excluded.count_type_9,
+ "is_closing_season": stmt.excluded.is_closing_season,
+ "last_updated": stmt.excluded.last_updated,
+ "update_type": stmt.excluded.update_type,
+ }
+
+ async def _upsert_with_fallback(
+ self, session: AsyncSession, rows: list[dict[str, Any]]
+ ) -> None:
+ for row in rows:
+ stmt = select(RoomAvailability).where(
+ RoomAvailability.inventory_id == row["inventory_id"],
+ RoomAvailability.date == row["date"],
+ )
+ result = await session.execute(stmt)
+ existing = result.scalar_one_or_none()
+
+ if existing:
+ existing.count_type_2 = row["count_type_2"]
+ existing.count_type_6 = row["count_type_6"]
+ existing.count_type_9 = row["count_type_9"]
+ existing.is_closing_season = row["is_closing_season"]
+ existing.last_updated = row["last_updated"]
+ existing.update_type = row["update_type"]
+ else:
+ session.add(RoomAvailability(**row))
+
+ def _success_response(self) -> AlpineBitsResponse:
+ response = OtaHotelInvCountNotifRs(version="7.000", success="")
+ xml = self._serializer.render(
+ response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
+ )
+ return AlpineBitsResponse(xml, HttpStatusCode.OK)
+
+ def _error_response(
+ self,
+ message: str,
+ status_code: HttpStatusCode,
+ error_type: ErrorType = ErrorType.VALUE_13,
+ code: str = "450",
+ ) -> AlpineBitsResponse:
+ error = OtaHotelInvCountNotifRs.Errors.Error(
+ type_value=error_type,
+ code=code,
+ content=[message],
+ )
+ errors = OtaHotelInvCountNotifRs.Errors(error=[error])
+ response = OtaHotelInvCountNotifRs(version="7.000", errors=errors)
+ xml = self._serializer.render(
+ response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
+ )
+ return AlpineBitsResponse(xml, status_code)
diff --git a/tests/test_api_freerooms.py b/tests/test_api_freerooms.py
new file mode 100644
index 0000000..b988d5a
--- /dev/null
+++ b/tests/test_api_freerooms.py
@@ -0,0 +1,215 @@
+"""Integration tests for the FreeRooms endpoint."""
+
+from __future__ import annotations
+
+import asyncio
+import gzip
+import urllib.parse
+from datetime import UTC, datetime
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from alpine_bits_python.alpinebits_server import AlpineBitsServer
+from alpine_bits_python.api import app
+from alpine_bits_python.const import HttpStatusCode
+from alpine_bits_python.db import Base, Hotel, RoomAvailability
+
+
+def build_request_xml(body: str, include_unique_id: bool = True) -> str:
+ unique = (
+ ''
+ if include_unique_id
+ else ""
+ )
+ return f"""
+
+ {unique}
+
+ {body}
+
+"""
+
+
+INVENTORY_A = """
+
+
+
+
+
+
+"""
+
+INVENTORY_B = """
+
+
+
+
+
+
+"""
+
+
+@pytest.fixture
+def freerooms_test_config():
+ return {
+ "server": {
+ "codecontext": "ADVERTISING",
+ "code": "70597314",
+ "companyname": "99tales Gmbh",
+ "res_id_source_context": "99tales",
+ },
+ "alpine_bits_auth": [
+ {
+ "hotel_id": "HOTEL123",
+ "hotel_name": "Integration Hotel",
+ "username": "testuser",
+ "password": "testpass",
+ }
+ ],
+ "database": {"url": "sqlite+aiosqlite:///:memory:"},
+ }
+
+
+@pytest.fixture
+def freerooms_client(freerooms_test_config):
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+
+ async def create_tables():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ asyncio.run(create_tables())
+
+ with patch("alpine_bits_python.api.load_config", return_value=freerooms_test_config), patch(
+ "alpine_bits_python.api.create_database_engine", return_value=engine
+ ):
+ app.state.engine = engine
+ app.state.async_sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
+ app.state.config = freerooms_test_config
+ app.state.alpine_bits_server = AlpineBitsServer(freerooms_test_config)
+
+ with TestClient(app) as test_client:
+ yield test_client
+
+
+@pytest.fixture
+def freerooms_headers():
+ return {
+ "Authorization": "Basic dGVzdHVzZXI6dGVzdHBhc3M=",
+ "X-AlpineBits-ClientProtocolVersion": "2024-10",
+ }
+
+
+def seed_hotel_if_missing(client: TestClient):
+ async def _seed():
+ async_sessionmaker = client.app.state.async_sessionmaker
+ async with async_sessionmaker() as session:
+ result = await session.execute(
+ select(Hotel).where(Hotel.hotel_id == "HOTEL123")
+ )
+ if result.scalar_one_or_none():
+ return
+ session.add(
+ Hotel(
+ hotel_id="HOTEL123",
+ hotel_name="Integration Hotel",
+ username="testuser",
+ password_hash="integration-hash",
+ created_at=datetime.now(UTC),
+ updated_at=datetime.now(UTC),
+ is_active=True,
+ )
+ )
+ await session.commit()
+
+ asyncio.run(_seed())
+
+
+def fetch_availability(client: TestClient):
+ async def _fetch():
+ async_sessionmaker = client.app.state.async_sessionmaker
+ async with async_sessionmaker() as session:
+ result = await session.execute(
+ select(RoomAvailability).order_by(RoomAvailability.date)
+ )
+ return result.scalars().all()
+
+ return asyncio.run(_fetch())
+
+
+def test_freerooms_endpoint_complete_set(freerooms_client: TestClient, freerooms_headers):
+ seed_hotel_if_missing(freerooms_client)
+ xml = build_request_xml(INVENTORY_A, include_unique_id=True)
+
+ response = freerooms_client.post(
+ "/api/alpinebits/server-2024-10",
+ data={"action": "OTA_HotelInvCountNotif:FreeRooms", "request": xml},
+ headers=freerooms_headers,
+ )
+
+ assert response.status_code == HttpStatusCode.OK
+ assert " str:
+ return f"""
+
+
+
+ {body}
+
+"""
+
+
+def build_delta_xml(body: str, hotel_code: str = "TESTHOTEL") -> str:
+ return f"""
+
+
+ {body}
+
+"""
+
+
+def daily_inventory(start: str, end: str, inv_type: str = "DBL", count: int = 3) -> str:
+ return f"""
+
+
+
+
+
+
+ """
+
+
+@pytest_asyncio.fixture
+async def db_engine():
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ yield engine
+ await engine.dispose()
+
+
+@pytest_asyncio.fixture
+async def db_session(db_engine):
+ session_factory = async_sessionmaker(db_engine, expire_on_commit=False, class_=AsyncSession)
+ async with session_factory() as session:
+ yield session
+
+
+async def insert_test_hotel(session: AsyncSession, hotel_id: str = "TESTHOTEL"):
+ hotel = Hotel(
+ hotel_id=hotel_id,
+ hotel_name="Unit Test Hotel",
+ username="testuser",
+ password_hash="bcrypt-hash",
+ created_at=datetime.now(UTC),
+ updated_at=datetime.now(UTC),
+ is_active=True,
+ )
+ session.add(hotel)
+ await session.commit()
+ return hotel
+
+
+def make_action() -> FreeRoomsAction:
+ return FreeRoomsAction(config=TEST_CONFIG)
+
+
+def make_client_info() -> AlpineBitsClientInfo:
+ return AlpineBitsClientInfo(username="testuser", password="testpass")
+
+
+@pytest.mark.asyncio
+async def test_complete_set_creates_inventory_and_availability(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ xml = build_complete_set_xml(
+ daily_inventory("2025-01-01", "2025-01-03", inv_type="DBL", count=4)
+ )
+
+ response = await action.handle(
+ action="OTA_HotelInvCountNotif:FreeRooms",
+ request_xml=xml,
+ version=Version.V2024_10,
+ client_info=make_client_info(),
+ dbsession=db_session,
+ )
+
+ assert response.status_code == HttpStatusCode.OK
+ inventories = (await db_session.execute(select(HotelInventory))).scalars().all()
+ assert len(inventories) == 1
+ assert inventories[0].inv_type_code == "DBL"
+ rows = (
+ await db_session.execute(
+ select(RoomAvailability).order_by(RoomAvailability.date)
+ )
+ ).scalars().all()
+ assert len(rows) == 3
+ assert rows[0].count_type_2 == 4
+ assert rows[0].update_type == "CompleteSet"
+
+
+@pytest.mark.asyncio
+async def test_complete_set_replaces_previous_availability(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ xml_initial = build_complete_set_xml(daily_inventory("2025-02-01", "2025-02-02", count=5))
+ xml_updated = build_complete_set_xml(daily_inventory("2025-02-01", "2025-02-01", count=1))
+
+ await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml_initial,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml_updated,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ rows = (
+ await db_session.execute(select(RoomAvailability).order_by(RoomAvailability.date))
+ ).scalars().all()
+ assert len(rows) == 1
+ assert rows[0].date.isoformat() == "2025-02-01"
+ assert rows[0].count_type_2 == 1
+
+
+@pytest.mark.asyncio
+async def test_delta_updates_only_specified_dates(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ complete_xml = build_complete_set_xml(daily_inventory("2025-03-01", "2025-03-03", count=2))
+ delta_xml = build_delta_xml(daily_inventory("2025-03-02", "2025-03-02", count=7))
+
+ await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ complete_xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+ await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ delta_xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ rows = (
+ await db_session.execute(select(RoomAvailability).order_by(RoomAvailability.date))
+ ).scalars().all()
+ counts = {row.date.isoformat(): row.count_type_2 for row in rows}
+ assert counts == {
+ "2025-03-01": 2,
+ "2025-03-02": 7,
+ "2025-03-03": 2,
+ }
+ assert all(row.update_type in {"CompleteSet", "Delta"} for row in rows)
+
+
+@pytest.mark.asyncio
+async def test_closing_season_entries_marked_correctly(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+ assert response.status_code == HttpStatusCode.OK
+
+ inventories = (await db_session.execute(select(HotelInventory))).scalars().all()
+ closing_inventory = next(inv for inv in inventories if inv.inv_type_code == "__CLOSE")
+ assert closing_inventory.inv_code is None
+
+ rows = (
+ await db_session.execute(select(RoomAvailability).order_by(RoomAvailability.date))
+ ).scalars().all()
+ closing_rows = [row for row in rows if row.is_closing_season]
+ assert len(closing_rows) == 2
+ assert all(row.count_type_2 is None for row in closing_rows)
+
+
+@pytest.mark.asyncio
+async def test_closing_season_not_allowed_in_delta(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ xml = build_delta_xml(
+ """
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "Closing seasons" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_missing_invtypecode_returns_error(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ xml = build_complete_set_xml(
+ """
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "InvTypeCode is required" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_duplicate_count_type_rejected(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ xml = build_complete_set_xml(
+ """
+
+
+
+
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "Duplicate CountType" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_invalid_date_range_returns_error(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ xml = build_complete_set_xml(
+ """
+
+
+
+ """
+ )
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "End date cannot be before Start date" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_invalid_credentials_return_unauthorized(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+ bad_client = AlpineBitsClientInfo(username="testuser", password="wrongpass")
+
+ xml = build_complete_set_xml(daily_inventory("2025-09-01", "2025-09-01"))
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ bad_client,
+ db_session,
+ )
+ assert response.status_code == HttpStatusCode.UNAUTHORIZED
+ assert "Unauthorized" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_invalid_xml_returns_error(db_session: AsyncSession):
+ await insert_test_hotel(db_session)
+ action = make_action()
+ client_info = make_client_info()
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ "