From f7158e7373b64c1200ddc7d5fc6d40969b83700e Mon Sep 17 00:00:00 2001
From: Jonas Linter <{email_address}>
Date: Thu, 27 Nov 2025 18:57:45 +0100
Subject: [PATCH 1/8] 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",
+ "
Date: Thu, 27 Nov 2025 19:35:30 +0100
Subject: [PATCH 2/8] Duplicate detection improved but refactoring necessary to
make the whole thing more managable
---
src/alpine_bits_python/db_setup.py | 199 +++++++-----
src/alpine_bits_python/webhook_processor.py | 56 +++-
tests/test_webhook_duplicates.py | 337 ++++++++++++++++++++
3 files changed, 502 insertions(+), 90 deletions(-)
create mode 100644 tests/test_webhook_duplicates.py
diff --git a/src/alpine_bits_python/db_setup.py b/src/alpine_bits_python/db_setup.py
index 82b69fa..bb526ca 100644
--- a/src/alpine_bits_python/db_setup.py
+++ b/src/alpine_bits_python/db_setup.py
@@ -249,110 +249,143 @@ async def reprocess_stuck_webhooks(
These are webhooks that were not fully processed in the previous run,
likely due to a crash or unexpected shutdown.
+ This function is designed to NEVER block application startup.
+ All errors are caught and logged, but the app will start regardless.
+
Args:
sessionmaker: SQLAlchemy async sessionmaker
config: Application configuration dictionary
"""
- _LOGGER.info("Checking for stuck webhooks to reprocess...")
+ try:
+ _LOGGER.info("Checking for stuck webhooks to reprocess...")
- async with sessionmaker() as session:
- # Find all webhooks stuck in 'processing' state
- result = await session.execute(
- select(WebhookRequest)
- .where(WebhookRequest.status == WebhookStatus.PROCESSING)
- .options(
- selectinload(WebhookRequest.webhook_endpoint).selectinload(
- WebhookEndpoint.hotel
+ async with sessionmaker() as session:
+ # Find all webhooks stuck in 'processing' state
+ result = await session.execute(
+ select(WebhookRequest)
+ .where(WebhookRequest.status == WebhookStatus.PROCESSING)
+ .options(
+ selectinload(WebhookRequest.webhook_endpoint).selectinload(
+ WebhookEndpoint.hotel
+ )
)
)
- )
- stuck_webhooks = result.scalars().all()
+ stuck_webhooks = result.scalars().all()
- if not stuck_webhooks:
- _LOGGER.info("No stuck webhooks found")
- return
+ if not stuck_webhooks:
+ _LOGGER.info("No stuck webhooks found")
+ return
- _LOGGER.info("Found %d stuck webhooks to reprocess", len(stuck_webhooks))
+ _LOGGER.info("Found %d stuck webhooks to reprocess", len(stuck_webhooks))
- reprocessed_count = 0
- failed_count = 0
+ reprocessed_count = 0
+ failed_count = 0
- for webhook_request in stuck_webhooks:
- webhook_id = webhook_request.id
- webhook_endpoint = webhook_request.webhook_endpoint
+ for webhook_request in stuck_webhooks:
+ webhook_id = webhook_request.id
+ webhook_endpoint = webhook_request.webhook_endpoint
- if not webhook_endpoint:
- _LOGGER.error(
- "Webhook request %d has no webhook_endpoint, skipping", webhook_id
- )
- webhook_request.status = WebhookStatus.FAILED
- webhook_request.last_error = (
- "No webhook endpoint found during startup reprocessing"
- )
- webhook_request.processing_completed_at = datetime.now(UTC)
- failed_count += 1
- continue
+ if not webhook_endpoint:
+ _LOGGER.error(
+ "Webhook request %d has no webhook_endpoint, skipping", webhook_id
+ )
+ webhook_request.status = WebhookStatus.FAILED
+ webhook_request.last_error = (
+ "No webhook endpoint found during startup reprocessing"
+ )
+ webhook_request.processing_completed_at = datetime.now(UTC)
+ failed_count += 1
+ continue
- if not webhook_request.payload_json:
- _LOGGER.error(
- "Webhook request %d has no payload (purged?), marking as failed",
- webhook_id,
- )
- webhook_request.status = WebhookStatus.FAILED
- webhook_request.last_error = (
- "No payload available for reprocessing (purged)"
- )
- webhook_request.processing_completed_at = datetime.now(UTC)
- failed_count += 1
- continue
+ if not webhook_request.payload_json:
+ _LOGGER.error(
+ "Webhook request %d has no payload (purged?), marking as failed",
+ webhook_id,
+ )
+ webhook_request.status = WebhookStatus.FAILED
+ webhook_request.last_error = (
+ "No payload available for reprocessing (purged)"
+ )
+ webhook_request.processing_completed_at = datetime.now(UTC)
+ failed_count += 1
+ continue
- try:
- _LOGGER.info(
- "Reprocessing webhook %d (hotel=%s, type=%s)",
- webhook_id,
- webhook_endpoint.hotel_id,
- webhook_endpoint.webhook_type,
- )
-
- # Get processor for webhook_type
- processor = webhook_registry.get_processor(
- webhook_endpoint.webhook_type
- )
- if not processor:
- raise ValueError(
- f"No processor for type: {webhook_endpoint.webhook_type}"
+ try:
+ _LOGGER.info(
+ "Reprocessing webhook %d (hotel=%s, type=%s)",
+ webhook_id,
+ webhook_endpoint.hotel_id,
+ webhook_endpoint.webhook_type,
)
- # Reprocess webhook with simplified interface
- await processor.process(
- webhook_request=webhook_request,
- db_session=session,
- config=config,
- )
+ # Get processor for webhook_type
+ processor = webhook_registry.get_processor(
+ webhook_endpoint.webhook_type
+ )
+ if not processor:
+ raise ValueError(
+ f"No processor for type: {webhook_endpoint.webhook_type}"
+ )
- # Update status to completed
- webhook_request.status = WebhookStatus.COMPLETED
- webhook_request.processing_completed_at = datetime.now(UTC)
- reprocessed_count += 1
+ # Reprocess webhook with simplified interface
+ result = await processor.process(
+ webhook_request=webhook_request,
+ db_session=session,
+ config=config,
+ )
- _LOGGER.info("Successfully reprocessed webhook %d", webhook_id)
+ # Check result status
+ result_status = result.get("status") if isinstance(result, dict) else "success"
- except Exception as e:
- _LOGGER.exception("Failed to reprocess webhook %d: %s", webhook_id, e)
- webhook_request.status = WebhookStatus.FAILED
- webhook_request.last_error = (
- f"Reprocessing failed during startup: {str(e)[:1950]}"
- )
- webhook_request.processing_completed_at = datetime.now(UTC)
- failed_count += 1
+ if result_status == "duplicate":
+ # Duplicate is not an error - mark as completed and continue
+ webhook_request.status = WebhookStatus.COMPLETED
+ webhook_request.processing_completed_at = datetime.now(UTC)
+ reprocessed_count += 1
+ _LOGGER.info(
+ "Webhook %d was a duplicate (already processed), marked as completed",
+ webhook_id
+ )
+ elif result_status in ("success", "completed"):
+ # Update status to completed
+ webhook_request.status = WebhookStatus.COMPLETED
+ webhook_request.processing_completed_at = datetime.now(UTC)
+ reprocessed_count += 1
+ _LOGGER.info("Successfully reprocessed webhook %d", webhook_id)
+ else:
+ # Unexpected status - treat as failure
+ _LOGGER.warning(
+ "Webhook %d returned unexpected status: %s",
+ webhook_id,
+ result_status
+ )
+ webhook_request.status = WebhookStatus.FAILED
+ webhook_request.last_error = f"Unexpected status: {result_status}"
+ webhook_request.processing_completed_at = datetime.now(UTC)
+ failed_count += 1
- # Commit all changes
- await session.commit()
+ except Exception as e:
+ _LOGGER.exception("Failed to reprocess webhook %d: %s", webhook_id, e)
+ webhook_request.status = WebhookStatus.FAILED
+ webhook_request.last_error = (
+ f"Reprocessing failed during startup: {str(e)[:1950]}"
+ )
+ webhook_request.processing_completed_at = datetime.now(UTC)
+ failed_count += 1
- _LOGGER.info(
- "Webhook reprocessing complete: %d successful, %d failed",
- reprocessed_count,
- failed_count,
+ # Commit all changes
+ await session.commit()
+
+ _LOGGER.info(
+ "Webhook reprocessing complete: %d successful, %d failed",
+ reprocessed_count,
+ failed_count,
+ )
+ except Exception as e:
+ # CRITICAL: Never let reprocessing block application startup
+ _LOGGER.exception(
+ "CRITICAL ERROR during webhook reprocessing, but allowing app to start: %s",
+ e
)
diff --git a/src/alpine_bits_python/webhook_processor.py b/src/alpine_bits_python/webhook_processor.py
index 4e37c15..293add5 100644
--- a/src/alpine_bits_python/webhook_processor.py
+++ b/src/alpine_bits_python/webhook_processor.py
@@ -273,10 +273,28 @@ async def process_wix_form_submission(
reservation, db_customer.id
)
except IntegrityError as e:
- _LOGGER.exception("Database integrity error creating reservation: %s", e)
- raise HTTPException(
- status_code=500, detail="Database error creating reservation"
- ) from e
+ await db_session.rollback()
+ # Check if this is a duplicate (unique constraint violation)
+ error_msg = str(e.orig) if hasattr(e, 'orig') else str(e)
+ is_duplicate = any(keyword in error_msg.lower() for keyword in ['unique', 'duplicate', 'already exists'])
+
+ if is_duplicate:
+ _LOGGER.info(
+ "Duplicate reservation detected for unique_id=%s, skipping (this is expected for reprocessing)",
+ unique_id
+ )
+ return {
+ "status": "duplicate",
+ "message": "Reservation already exists (duplicate submission)",
+ "unique_id": unique_id,
+ "timestamp": timestamp,
+ }
+ else:
+ # Real integrity error (not a duplicate)
+ _LOGGER.exception("Database integrity error creating reservation: %s", e)
+ raise HTTPException(
+ status_code=500, detail="Database error creating reservation"
+ ) from e
async def push_event():
# Fire event for listeners (push, etc.) - hotel-specific dispatch
@@ -581,9 +599,33 @@ async def process_generic_webhook_submission(
# Use ReservationService to create reservation
reservation_service = ReservationService(db_session)
- db_reservation = await reservation_service.create_reservation(
- reservation, db_customer.id
- )
+ try:
+ db_reservation = await reservation_service.create_reservation(
+ reservation, db_customer.id
+ )
+ except IntegrityError as e:
+ await db_session.rollback()
+ # Check if this is a duplicate (unique constraint violation)
+ error_msg = str(e.orig) if hasattr(e, 'orig') else str(e)
+ is_duplicate = any(keyword in error_msg.lower() for keyword in ['unique', 'duplicate', 'already exists'])
+
+ if is_duplicate:
+ _LOGGER.info(
+ "Duplicate reservation detected for unique_id=%s, skipping (this is expected for reprocessing)",
+ unique_id
+ )
+ return {
+ "status": "duplicate",
+ "message": "Reservation already exists (duplicate submission)",
+ "unique_id": unique_id,
+ "timestamp": timestamp,
+ }
+ else:
+ # Real integrity error (not a duplicate)
+ _LOGGER.exception("Database integrity error creating reservation: %s", e)
+ raise HTTPException(
+ status_code=500, detail="Database error creating reservation"
+ ) from e
async def push_event():
# Fire event for listeners (push, etc.) - hotel-specific dispatch
diff --git a/tests/test_webhook_duplicates.py b/tests/test_webhook_duplicates.py
new file mode 100644
index 0000000..99c5741
--- /dev/null
+++ b/tests/test_webhook_duplicates.py
@@ -0,0 +1,337 @@
+"""Tests for webhook duplicate handling and reprocessing.
+
+This module tests:
+- Duplicate detection during normal operation
+- Duplicate handling during app startup reprocessing
+- Stuck webhooks that are duplicates
+"""
+
+import asyncio
+import uuid
+from datetime import UTC, datetime
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+import pytest_asyncio
+from fastapi.testclient import TestClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from alpine_bits_python.api import app
+from alpine_bits_python.const import WebhookStatus
+from alpine_bits_python.db import Base, Reservation, WebhookRequest
+from alpine_bits_python.db_setup import reprocess_stuck_webhooks
+
+
+@pytest_asyncio.fixture
+async def test_db_engine():
+ """Create an in-memory SQLite database for testing."""
+ engine = create_async_engine(
+ "sqlite+aiosqlite:///:memory:",
+ echo=False,
+ )
+
+ # Create tables
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ yield engine
+
+ # Cleanup
+ await engine.dispose()
+
+
+@pytest.fixture
+def test_config():
+ """Test configuration."""
+ return {
+ "server": {
+ "codecontext": "ADVERTISING",
+ "code": "70597314",
+ "companyname": "99tales Gmbh",
+ "res_id_source_context": "99tales",
+ },
+ "alpine_bits_auth": [
+ {
+ "hotel_id": "HOTEL123",
+ "hotel_name": "Test Hotel",
+ "username": "testuser",
+ "password": "testpass",
+ }
+ ],
+ "default_hotel_code": "HOTEL123",
+ "default_hotel_name": "Test Hotel",
+ "database": {"url": "sqlite+aiosqlite:///:memory:"},
+ }
+
+
+@pytest.fixture
+def sample_wix_form_data():
+ """Sample Wix form submission data with FIXED submissionId for duplicate testing."""
+ return {
+ "data": {
+ "submissionId": "FIXED-DUPLICATE-TEST-ID", # Fixed ID to trigger duplicates
+ "submissionTime": "2025-10-07T05:48:41.855Z",
+ "contact": {
+ "name": {"first": "John", "last": "Doe"},
+ "email": "john.doe.duplicate.test@example.com",
+ "phones": [{"e164Phone": "+1234567890"}],
+ "locale": "en-US",
+ "contactId": "contact-duplicate-test",
+ },
+ "field:anrede": "Mr.",
+ "field:form_field_5a7b": True,
+ "field:date_picker_a7c8": "2024-12-25",
+ "field:date_picker_7e65": "2024-12-31",
+ "field:number_7cf5": "2",
+ "field:anzahl_kinder": "1",
+ "field:alter_kind_1": "8",
+ "field:angebot_auswaehlen": "Christmas Special",
+ "field:utm_source": "google",
+ "field:utm_medium": "cpc",
+ "field:utm_campaign": "winter2024",
+ "field:fbclid": "test_fbclid_123",
+ "field:long_answer_3524": "Late check-in please",
+ }
+ }
+
+
+class TestWebhookDuplicateHandling:
+ """Test duplicate webhook handling during normal operation."""
+
+ def test_duplicate_webhook_during_operation(self, test_config, sample_wix_form_data):
+ """Test that sending the same webhook twice handles duplicates gracefully."""
+ # Create engine and tables
+ 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())
+
+ # Mock config and database to use our test database
+ with patch("alpine_bits_python.api.load_config", return_value=test_config), \
+ patch("alpine_bits_python.api.create_database_engine", return_value=engine):
+
+ from alpine_bits_python.alpinebits_server import AlpineBitsServer
+
+ # Setup app state
+ app.state.engine = engine
+ app.state.async_sessionmaker = async_sessionmaker(
+ engine, expire_on_commit=False
+ )
+ app.state.config = test_config
+ app.state.alpine_bits_server = AlpineBitsServer(test_config)
+
+ with TestClient(app) as client:
+ # First submission - should succeed
+ response1 = client.post(
+ "/api/webhook/wix-form",
+ json=sample_wix_form_data
+ )
+ assert response1.status_code == 200
+ data1 = response1.json()
+ assert data1["status"] == "success"
+
+ # Second submission with same data - should detect duplicate at API level
+ response2 = client.post(
+ "/api/webhook/wix-form",
+ json=sample_wix_form_data
+ )
+ assert response2.status_code == 200
+ data2 = response2.json()
+ # API returns success for already-processed webhooks, but sets duplicate flag
+ assert data2["status"] == "success"
+ assert data2.get("duplicate") is True
+ assert "already processed" in data2["message"].lower()
+
+ # Cleanup
+ asyncio.run(engine.dispose())
+
+
+class TestWebhookReprocessing:
+ """Test webhook reprocessing on app restart."""
+
+ @pytest.mark.asyncio
+ async def test_reprocess_stuck_duplicate_webhook(self, test_db_engine, test_config):
+ """Test that stuck webhooks that are duplicates are handled correctly on restart."""
+ AsyncSessionLocal = async_sessionmaker(test_db_engine, expire_on_commit=False)
+
+ # Step 1: Process a webhook normally to create a reservation
+ from alpine_bits_python.webhook_processor import process_wix_form_submission
+
+ test_data = {
+ "data": {
+ "submissionId": "STUCK-WEBHOOK-TEST-ID",
+ "submissionTime": "2025-10-07T05:48:41.855Z",
+ "contact": {
+ "name": {"first": "Jane", "last": "Smith"},
+ "email": "jane.smith@example.com",
+ "phones": [{"e164Phone": "+9876543210"}],
+ "locale": "en-US",
+ "contactId": "contact-stuck-test",
+ },
+ "field:date_picker_a7c8": "2024-12-25",
+ "field:date_picker_7e65": "2024-12-31",
+ "field:number_7cf5": "2",
+ "field:anzahl_kinder": "0",
+ }
+ }
+
+ async with AsyncSessionLocal() as session:
+ result = await process_wix_form_submission(
+ test_data, session, config=test_config
+ )
+ await session.commit()
+ assert result["status"] == "success"
+
+ # Step 2: Verify the reservation was created
+ async with AsyncSessionLocal() as session:
+ stmt = select(Reservation).where(
+ Reservation.unique_id == "STUCK-WEBHOOK-TEST-ID"
+ )
+ result = await session.execute(stmt)
+ reservation = result.scalar_one_or_none()
+ assert reservation is not None
+ assert reservation.unique_id == "STUCK-WEBHOOK-TEST-ID"
+
+ # Step 3: Manually create a webhook request stuck in "processing" status
+ # This simulates a webhook that was being processed when the app crashed
+ from alpine_bits_python.db import WebhookEndpoint, Hotel
+
+ async with AsyncSessionLocal() as session:
+ # Create hotel
+ hotel = Hotel(
+ hotel_id="HOTEL123",
+ hotel_name="Test Hotel",
+ username="testuser",
+ password_hash="dummy",
+ created_at=datetime.now(UTC),
+ updated_at=datetime.now(UTC),
+ is_active=True,
+ )
+ session.add(hotel)
+ await session.flush()
+
+ # Create webhook endpoint
+ endpoint = WebhookEndpoint(
+ hotel_id="HOTEL123",
+ webhook_type="wix_form",
+ webhook_secret="test-secret-123",
+ is_enabled=True,
+ created_at=datetime.now(UTC),
+ )
+ session.add(endpoint)
+ await session.flush()
+
+ # Create stuck webhook request with the SAME payload
+ stuck_webhook = WebhookRequest(
+ webhook_endpoint_id=endpoint.id,
+ hotel_id="HOTEL123",
+ payload_json=test_data,
+ status=WebhookStatus.PROCESSING, # Stuck in processing!
+ created_at=datetime.now(UTC),
+ )
+ session.add(stuck_webhook)
+ await session.commit()
+
+ # Step 4: Run reprocessing (simulates app restart)
+ await reprocess_stuck_webhooks(AsyncSessionLocal, test_config)
+
+ # Step 5: Verify the stuck webhook was marked as completed (not failed)
+ async with AsyncSessionLocal() as session:
+ stmt = select(WebhookRequest).where(
+ WebhookRequest.status == WebhookStatus.COMPLETED
+ )
+ result = await session.execute(stmt)
+ completed_webhooks = result.scalars().all()
+ assert len(completed_webhooks) == 1
+ assert completed_webhooks[0].last_error is None
+
+ # Verify no failed webhooks
+ stmt = select(WebhookRequest).where(
+ WebhookRequest.status == WebhookStatus.FAILED
+ )
+ result = await session.execute(stmt)
+ failed_webhooks = result.scalars().all()
+ assert len(failed_webhooks) == 0
+
+ # Step 6: Verify only ONE reservation exists (no duplicate)
+ async with AsyncSessionLocal() as session:
+ stmt = select(Reservation)
+ result = await session.execute(stmt)
+ reservations = result.scalars().all()
+ assert len(reservations) == 1
+
+
+class TestWebhookReprocessingNeverBlocksStartup:
+ """Test that reprocessing never blocks app startup."""
+
+ @pytest.mark.asyncio
+ async def test_reprocessing_error_does_not_block_startup(
+ self, test_db_engine, test_config
+ ):
+ """Test that even if reprocessing fails, app startup continues."""
+ AsyncSessionLocal = async_sessionmaker(test_db_engine, expire_on_commit=False)
+
+ from alpine_bits_python.db import WebhookEndpoint, Hotel
+
+ # Create a stuck webhook with invalid data that will cause processing to fail
+ async with AsyncSessionLocal() as session:
+ # Create hotel
+ hotel = Hotel(
+ hotel_id="HOTEL123",
+ hotel_name="Test Hotel",
+ username="testuser",
+ password_hash="dummy",
+ created_at=datetime.now(UTC),
+ updated_at=datetime.now(UTC),
+ is_active=True,
+ )
+ session.add(hotel)
+ await session.flush()
+
+ # Create webhook endpoint
+ endpoint = WebhookEndpoint(
+ hotel_id="HOTEL123",
+ webhook_type="wix_form",
+ webhook_secret="test-secret-123",
+ is_enabled=True,
+ created_at=datetime.now(UTC),
+ )
+ session.add(endpoint)
+ await session.flush()
+
+ # Create stuck webhook with INVALID data (missing required fields)
+ stuck_webhook = WebhookRequest(
+ webhook_endpoint_id=endpoint.id,
+ hotel_id="HOTEL123",
+ payload_json={"data": {"invalid": "data"}}, # Missing required fields
+ status=WebhookStatus.PROCESSING,
+ received_at=datetime.now(UTC),
+ )
+ session.add(stuck_webhook)
+ await session.commit()
+
+ # This should NOT raise an exception - it should log and continue
+ try:
+ await reprocess_stuck_webhooks(AsyncSessionLocal, test_config)
+ except Exception as e:
+ pytest.fail(
+ f"reprocess_stuck_webhooks should NEVER raise exceptions, but got: {e}"
+ )
+
+ # Verify the webhook was marked as failed
+ async with AsyncSessionLocal() as session:
+ stmt = select(WebhookRequest).where(
+ WebhookRequest.status == WebhookStatus.FAILED
+ )
+ result = await session.execute(stmt)
+ failed_webhooks = result.scalars().all()
+ assert len(failed_webhooks) == 1
+ assert failed_webhooks[0].last_error is not None
From a1d9ef5fea0560fdeaf4480ec1bad767ebaa772e Mon Sep 17 00:00:00 2001
From: Jonas Linter <{email_address}>
Date: Thu, 27 Nov 2025 19:59:38 +0100
Subject: [PATCH 3/8] Offers get extracted from generic webhooks and added to
reservations
---
src/alpine_bits_python/webhook_processor.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/alpine_bits_python/webhook_processor.py b/src/alpine_bits_python/webhook_processor.py
index 293add5..9431c15 100644
--- a/src/alpine_bits_python/webhook_processor.py
+++ b/src/alpine_bits_python/webhook_processor.py
@@ -421,7 +421,7 @@ async def process_generic_webhook_submission(
hotel_data = data.get("hotel_data", {})
form_data = data.get("form_data", {})
tracking_data = data.get("tracking_data", {})
- offer_data = data.get("unterkunftTyp", {})
+ offer_data = form_data.get("unterkunftTyp", {})
selected_offers = []
From a80f66bd4519a45d26a4df2d965eb139e4d8bfb4 Mon Sep 17 00:00:00 2001
From: Jonas Linter <{email_address}>
Date: Mon, 1 Dec 2025 09:21:15 +0100
Subject: [PATCH 4/8] Fix integrity error by adding dummy payload_hash in
webhook reprocessing test
---
tests/test_webhook_duplicates.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/tests/test_webhook_duplicates.py b/tests/test_webhook_duplicates.py
index 99c5741..25625eb 100644
--- a/tests/test_webhook_duplicates.py
+++ b/tests/test_webhook_duplicates.py
@@ -313,9 +313,10 @@ class TestWebhookReprocessingNeverBlocksStartup:
hotel_id="HOTEL123",
payload_json={"data": {"invalid": "data"}}, # Missing required fields
status=WebhookStatus.PROCESSING,
- received_at=datetime.now(UTC),
+ created_at=datetime.now(UTC),
+ payload_hash="invalidhash" # Add a dummy payload_hash to avoid integrity error
)
- session.add(stuck_webhook)
+ session.add(stuck_webhook) ## Cannot add the stuck webhook. Integrity Error payload_hash is missing
await session.commit()
# This should NOT raise an exception - it should log and continue
From 3e577a499fe427ad6fac1212d1f6414e83fa03d0 Mon Sep 17 00:00:00 2001
From: Jonas Linter <{email_address}>
Date: Mon, 1 Dec 2025 09:21:25 +0100
Subject: [PATCH 5/8] Small typing addition
---
src/alpine_bits_python/db_setup.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/alpine_bits_python/db_setup.py b/src/alpine_bits_python/db_setup.py
index bb526ca..b56a28a 100644
--- a/src/alpine_bits_python/db_setup.py
+++ b/src/alpine_bits_python/db_setup.py
@@ -270,7 +270,7 @@ async def reprocess_stuck_webhooks(
)
)
)
- stuck_webhooks = result.scalars().all()
+ stuck_webhooks: list[WebhookRequest] = result.scalars().all()
if not stuck_webhooks:
_LOGGER.info("No stuck webhooks found")
From 2be10ff89997d55647095fb2fb1820189081cc14 Mon Sep 17 00:00:00 2001
From: Jonas Linter <{email_address}>
Date: Mon, 1 Dec 2025 10:14:14 +0100
Subject: [PATCH 6/8] Fixed some tests and added schemas
---
src/alpine_bits_python/api.py | 10 +-
src/alpine_bits_python/schemas.py | 146 ++++++++++++++++++++
tests/test_schemas_webhook.py | 218 ++++++++++++++++++++++++++++++
tests/test_webhook_duplicates.py | 52 +++----
4 files changed, 399 insertions(+), 27 deletions(-)
create mode 100644 tests/test_schemas_webhook.py
diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py
index 1c57335..8c44ee5 100644
--- a/src/alpine_bits_python/api.py
+++ b/src/alpine_bits_python/api.py
@@ -32,6 +32,8 @@ from sqlalchemy import and_, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload
+from alpine_bits_python.schemas import WebhookRequestData
+
from .alpinebits_server import (
AlpineBitsActionName,
AlpineBitsClientInfo,
@@ -888,8 +890,9 @@ async def handle_webhook_unified(
webhook_request.status = WebhookStatus.PROCESSING
webhook_request.processing_started_at = timestamp
else:
- # 5. Create new webhook_request
- webhook_request = WebhookRequest(
+
+
+ webhook_request_data = WebhookRequestData(
payload_hash=payload_hash,
webhook_endpoint_id=webhook_endpoint.id,
hotel_id=webhook_endpoint.hotel_id,
@@ -900,6 +903,9 @@ async def handle_webhook_unified(
source_ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
+ # 5. Create new webhook_request
+ webhook_request = WebhookRequest(**webhook_request_data.model_dump())
+
db_session.add(webhook_request)
await db_session.flush()
diff --git a/src/alpine_bits_python/schemas.py b/src/alpine_bits_python/schemas.py
index 375edc2..0288006 100644
--- a/src/alpine_bits_python/schemas.py
+++ b/src/alpine_bits_python/schemas.py
@@ -10,11 +10,15 @@ from XML generation (xsdata) follows clean architecture principles.
"""
import hashlib
+import json
from datetime import date, datetime
from enum import Enum
+from typing import Any
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
+from .const import WebhookStatus
+
# Country name to ISO 3166-1 alpha-2 code mapping
COUNTRY_NAME_TO_CODE = {
@@ -308,6 +312,148 @@ class CommentsData(BaseModel):
model_config = {"from_attributes": True}
+class HotelData(BaseModel):
+ """Validated hotel configuration data."""
+
+ hotel_id: str = Field(..., min_length=1, max_length=50)
+ hotel_name: str = Field(..., min_length=1, max_length=200)
+ username: str = Field(..., min_length=1, max_length=100)
+ password_hash: str = Field(..., min_length=1, max_length=200)
+ meta_account_id: str | None = Field(None, max_length=50)
+ google_account_id: str | None = Field(None, max_length=50)
+ push_endpoint_url: str | None = Field(None, max_length=500)
+ push_endpoint_token: str | None = Field(None, max_length=200)
+ push_endpoint_username: str | None = Field(None, max_length=100)
+ created_at: datetime = Field(default_factory=lambda: datetime.now())
+ updated_at: datetime = Field(default_factory=lambda: datetime.now())
+ is_active: bool = Field(default=True)
+
+ @field_validator("hotel_id", "hotel_name", "username")
+ @classmethod
+ def strip_whitespace(cls, v: str) -> str:
+ """Remove leading/trailing whitespace."""
+ return v.strip()
+
+ model_config = {"from_attributes": True}
+
+
+class WebhookEndpointData(BaseModel):
+ """Validated webhook endpoint configuration data."""
+
+ hotel_id: str = Field(..., min_length=1, max_length=50)
+ webhook_secret: str = Field(..., min_length=1, max_length=64)
+ webhook_type: str = Field(..., min_length=1, max_length=50)
+ description: str | None = Field(None, max_length=200)
+ is_enabled: bool = Field(default=True)
+ created_at: datetime = Field(default_factory=lambda: datetime.now())
+
+ @field_validator("hotel_id", "webhook_secret", "webhook_type")
+ @classmethod
+ def strip_whitespace(cls, v: str) -> str:
+ """Remove leading/trailing whitespace."""
+ return v.strip()
+
+ model_config = {"from_attributes": True}
+
+
+class WebhookRequestData(BaseModel):
+ """Validated webhook request data.
+
+ This model handles the special case where:
+ - payload_json is required for creation (to calculate payload_hash)
+ - payload_json becomes optional after processing (can be purged for privacy/storage)
+ - payload_hash is auto-calculated from payload_json when provided
+ """
+
+ # Required fields
+ payload_json: dict[str, Any] | None = Field(
+ ...,
+ description="Webhook payload (required for creation, nullable after purge)"
+ )
+
+ # Auto-calculated from payload_json
+ payload_hash: str | None = Field(
+ None,
+ min_length=64,
+ max_length=64,
+ description="SHA256 hash of canonical JSON payload (auto-calculated)"
+ )
+
+ # Optional foreign keys
+ webhook_endpoint_id: int | None = Field(None, gt=0)
+ hotel_id: str | None = Field(None, max_length=50)
+
+ # Processing tracking
+ status: WebhookStatus = Field(default=WebhookStatus.PENDING)
+ processing_started_at: datetime | None = None
+ processing_completed_at: datetime | None = None
+
+ # Retry handling
+ retry_count: int = Field(default=0, ge=0)
+ last_error: str | None = Field(None, max_length=2000)
+
+ # Payload metadata
+ purged_at: datetime | None = None
+
+ # Request metadata
+ created_at: datetime = Field(default_factory=lambda: datetime.now())
+ source_ip: str | None = Field(None, max_length=45)
+ user_agent: str | None = Field(None, max_length=500)
+
+ # Result tracking
+ created_customer_id: int | None = Field(None, gt=0)
+ created_reservation_id: int | None = Field(None, gt=0)
+
+ @model_validator(mode="after")
+ def calculate_payload_hash(self) -> "WebhookRequestData":
+ """Auto-calculate payload_hash from payload_json if not provided.
+
+ Uses the same hashing algorithm as api.py:
+ - Canonical JSON with sorted keys
+ - UTF-8 encoding
+ - SHA256 hash
+
+ This runs after all field validation, so we can access the validated payload_json.
+ """
+ # Only calculate if payload_json is provided and payload_hash is not set
+ if self.payload_json is not None and self.payload_hash is None:
+ # Create canonical JSON string (sorted keys for consistency)
+ payload_json_str = json.dumps(self.payload_json, sort_keys=True)
+ # Calculate SHA256 hash
+ self.payload_hash = hashlib.sha256(
+ payload_json_str.encode("utf-8")
+ ).hexdigest()
+
+ return self
+
+ @model_validator(mode="after")
+ def validate_payload_hash_requirements(self) -> "WebhookRequestData":
+ """Ensure payload_hash is present (either provided or calculated).
+
+ This validator runs after calculate_payload_hash, so payload_hash should
+ be set if payload_json was provided.
+ """
+ if self.payload_hash is None:
+ raise ValueError(
+ "payload_hash is required. It can be auto-calculated from payload_json "
+ "or explicitly provided."
+ )
+
+ return self
+
+ @field_validator("status", mode="before")
+ @classmethod
+ def normalize_status(cls, v: str | WebhookStatus) -> WebhookStatus:
+ """Normalize status to WebhookStatus enum."""
+ if isinstance(v, WebhookStatus):
+ return v
+ if isinstance(v, str):
+ return WebhookStatus(v)
+ raise ValueError(f"Invalid webhook status: {v}")
+
+ model_config = {"from_attributes": True}
+
+
# Example usage in a service layer
class ReservationService:
"""Example service showing how to use Pydantic models with SQLAlchemy."""
diff --git a/tests/test_schemas_webhook.py b/tests/test_schemas_webhook.py
new file mode 100644
index 0000000..d615f5d
--- /dev/null
+++ b/tests/test_schemas_webhook.py
@@ -0,0 +1,218 @@
+"""Tests for webhook-related Pydantic schemas."""
+
+import hashlib
+import json
+from datetime import datetime
+
+import pytest
+from pydantic import ValidationError
+
+from alpine_bits_python.const import WebhookStatus
+from alpine_bits_python.schemas import (
+ HotelData,
+ WebhookEndpointData,
+ WebhookRequestData,
+)
+
+
+class TestHotelData:
+ """Tests for HotelData schema."""
+
+ def test_valid_hotel_data(self):
+ """Test creating a valid HotelData instance."""
+ data = HotelData(
+ hotel_id="hotel123",
+ hotel_name="Test Hotel",
+ username="admin",
+ password_hash="hashed_password_123",
+ )
+ assert data.hotel_id == "hotel123"
+ assert data.hotel_name == "Test Hotel"
+ assert data.username == "admin"
+ assert data.password_hash == "hashed_password_123"
+ assert data.is_active is True
+ assert isinstance(data.created_at, datetime)
+
+ def test_whitespace_stripping(self):
+ """Test that whitespace is stripped from string fields."""
+ data = HotelData(
+ hotel_id=" hotel123 ",
+ hotel_name=" Test Hotel ",
+ username=" admin ",
+ password_hash="hashed_password_123",
+ )
+ assert data.hotel_id == "hotel123"
+ assert data.hotel_name == "Test Hotel"
+ assert data.username == "admin"
+
+ def test_optional_fields(self):
+ """Test that optional fields can be None."""
+ data = HotelData(
+ hotel_id="hotel123",
+ hotel_name="Test Hotel",
+ username="admin",
+ password_hash="hashed_password_123",
+ meta_account_id=None,
+ google_account_id=None,
+ )
+ assert data.meta_account_id is None
+ assert data.google_account_id is None
+
+
+class TestWebhookEndpointData:
+ """Tests for WebhookEndpointData schema."""
+
+ def test_valid_webhook_endpoint(self):
+ """Test creating a valid WebhookEndpointData instance."""
+ data = WebhookEndpointData(
+ hotel_id="hotel123",
+ webhook_secret="secret_abc123",
+ webhook_type="wix_form",
+ )
+ assert data.hotel_id == "hotel123"
+ assert data.webhook_secret == "secret_abc123"
+ assert data.webhook_type == "wix_form"
+ assert data.is_enabled is True
+ assert isinstance(data.created_at, datetime)
+
+ def test_webhook_endpoint_with_description(self):
+ """Test WebhookEndpointData with optional description."""
+ data = WebhookEndpointData(
+ hotel_id="hotel123",
+ webhook_secret="secret_abc123",
+ webhook_type="generic",
+ description="Main booking form",
+ )
+ assert data.description == "Main booking form"
+
+ def test_whitespace_stripping(self):
+ """Test that whitespace is stripped from string fields."""
+ data = WebhookEndpointData(
+ hotel_id=" hotel123 ",
+ webhook_secret=" secret_abc123 ",
+ webhook_type=" wix_form ",
+ )
+ assert data.hotel_id == "hotel123"
+ assert data.webhook_secret == "secret_abc123"
+ assert data.webhook_type == "wix_form"
+
+
+class TestWebhookRequestData:
+ """Tests for WebhookRequestData schema."""
+
+ def test_auto_calculate_payload_hash(self):
+ """Test that payload_hash is auto-calculated from payload_json."""
+ payload = {"name": "John", "email": "john@example.com"}
+ data = WebhookRequestData(payload_json=payload)
+
+ # Verify hash was calculated
+ assert data.payload_hash is not None
+ assert len(data.payload_hash) == 64 # SHA256 produces 64 hex chars
+
+ # Verify it matches the expected hash (same algorithm as api.py)
+ payload_json_str = json.dumps(payload, sort_keys=True)
+ expected_hash = hashlib.sha256(payload_json_str.encode("utf-8")).hexdigest()
+ assert data.payload_hash == expected_hash
+
+ def test_explicit_payload_hash(self):
+ """Test providing payload_hash explicitly (for purged payloads)."""
+ explicit_hash = "a" * 64
+ data = WebhookRequestData(
+ payload_json=None,
+ payload_hash=explicit_hash,
+ )
+ assert data.payload_hash == explicit_hash
+ assert data.payload_json is None
+
+ def test_payload_hash_required(self):
+ """Test that payload_hash is required (either calculated or explicit)."""
+ with pytest.raises(ValidationError) as exc_info:
+ WebhookRequestData(
+ payload_json=None,
+ payload_hash=None,
+ )
+ assert "payload_hash is required" in str(exc_info.value)
+
+ def test_consistent_hashing(self):
+ """Test that the same payload always produces the same hash."""
+ payload = {"b": 2, "a": 1, "c": 3} # Unordered keys
+
+ data1 = WebhookRequestData(payload_json=payload.copy())
+ data2 = WebhookRequestData(payload_json=payload.copy())
+
+ assert data1.payload_hash == data2.payload_hash
+
+ def test_default_status(self):
+ """Test that status defaults to PENDING."""
+ data = WebhookRequestData(payload_json={"test": "data"})
+ assert data.status == WebhookStatus.PENDING
+
+ def test_status_normalization(self):
+ """Test that status is normalized to WebhookStatus enum."""
+ data = WebhookRequestData(
+ payload_json={"test": "data"},
+ status="completed", # String
+ )
+ assert data.status == WebhookStatus.COMPLETED
+ assert isinstance(data.status, WebhookStatus)
+
+ def test_retry_count_default(self):
+ """Test that retry_count defaults to 0."""
+ data = WebhookRequestData(payload_json={"test": "data"})
+ assert data.retry_count == 0
+
+ def test_optional_foreign_keys(self):
+ """Test optional foreign key fields."""
+ data = WebhookRequestData(
+ payload_json={"test": "data"},
+ webhook_endpoint_id=123,
+ hotel_id="hotel456",
+ )
+ assert data.webhook_endpoint_id == 123
+ assert data.hotel_id == "hotel456"
+
+ def test_result_tracking(self):
+ """Test result tracking fields."""
+ data = WebhookRequestData(
+ payload_json={"test": "data"},
+ created_customer_id=1,
+ created_reservation_id=2,
+ )
+ assert data.created_customer_id == 1
+ assert data.created_reservation_id == 2
+
+ def test_purged_payload(self):
+ """Test representing a purged webhook request (after processing)."""
+ explicit_hash = "b" * 64
+ data = WebhookRequestData(
+ payload_json=None,
+ payload_hash=explicit_hash,
+ status=WebhookStatus.COMPLETED,
+ purged_at=datetime.now(),
+ )
+ assert data.payload_json is None
+ assert data.payload_hash == explicit_hash
+ assert data.status == WebhookStatus.COMPLETED
+ assert data.purged_at is not None
+
+ def test_processing_metadata(self):
+ """Test processing tracking fields."""
+ now = datetime.now()
+ data = WebhookRequestData(
+ payload_json={"test": "data"},
+ status=WebhookStatus.PROCESSING,
+ processing_started_at=now,
+ )
+ assert data.status == WebhookStatus.PROCESSING
+ assert data.processing_started_at == now
+ assert data.processing_completed_at is None
+
+ def test_request_metadata(self):
+ """Test request metadata fields."""
+ data = WebhookRequestData(
+ payload_json={"test": "data"},
+ source_ip="192.168.1.1",
+ user_agent="Mozilla/5.0",
+ )
+ assert data.source_ip == "192.168.1.1"
+ assert data.user_agent == "Mozilla/5.0"
diff --git a/tests/test_webhook_duplicates.py b/tests/test_webhook_duplicates.py
index 25625eb..57580d7 100644
--- a/tests/test_webhook_duplicates.py
+++ b/tests/test_webhook_duplicates.py
@@ -7,6 +7,7 @@ This module tests:
"""
import asyncio
+import json
import uuid
from datetime import UTC, datetime
from pathlib import Path
@@ -22,6 +23,8 @@ from alpine_bits_python.api import app
from alpine_bits_python.const import WebhookStatus
from alpine_bits_python.db import Base, Reservation, WebhookRequest
from alpine_bits_python.db_setup import reprocess_stuck_webhooks
+from alpine_bits_python.schemas import WebhookRequestData
+from alpine_bits_python.webhook_processor import initialize_webhook_processors, webhook_registry
@pytest_asyncio.fixture
@@ -165,23 +168,16 @@ class TestWebhookReprocessing:
# Step 1: Process a webhook normally to create a reservation
from alpine_bits_python.webhook_processor import process_wix_form_submission
- test_data = {
- "data": {
- "submissionId": "STUCK-WEBHOOK-TEST-ID",
- "submissionTime": "2025-10-07T05:48:41.855Z",
- "contact": {
- "name": {"first": "Jane", "last": "Smith"},
- "email": "jane.smith@example.com",
- "phones": [{"e164Phone": "+9876543210"}],
- "locale": "en-US",
- "contactId": "contact-stuck-test",
- },
- "field:date_picker_a7c8": "2024-12-25",
- "field:date_picker_7e65": "2024-12-31",
- "field:number_7cf5": "2",
- "field:anzahl_kinder": "0",
- }
- }
+ test_form_file = Path(__file__).parent / "test_data" / f"test_form{1}.json"
+
+ if not test_form_file.exists():
+ pytest.skip(f"{test_form_file.name} not found")
+
+ # Load test form data
+ with test_form_file.open() as f:
+ test_data = json.load(f)
+
+ test_data["data"]["submissionId"] = "STUCK-WEBHOOK-TEST-ID" # Fixed ID for duplicate test
async with AsyncSessionLocal() as session:
result = await process_wix_form_submission(
@@ -197,7 +193,7 @@ class TestWebhookReprocessing:
)
result = await session.execute(stmt)
reservation = result.scalar_one_or_none()
- assert reservation is not None
+ assert reservation is not None, "Reservation should exist"
assert reservation.unique_id == "STUCK-WEBHOOK-TEST-ID"
# Step 3: Manually create a webhook request stuck in "processing" status
@@ -230,16 +226,23 @@ class TestWebhookReprocessing:
await session.flush()
# Create stuck webhook request with the SAME payload
- stuck_webhook = WebhookRequest(
+ stuck_webhook_data = WebhookRequestData(
webhook_endpoint_id=endpoint.id,
hotel_id="HOTEL123",
payload_json=test_data,
status=WebhookStatus.PROCESSING, # Stuck in processing!
created_at=datetime.now(UTC),
)
+
+ stuck_webhook = WebhookRequest(**stuck_webhook_data.model_dump())
session.add(stuck_webhook)
await session.commit()
+ # initialize wix_form processor
+
+ initialize_webhook_processors()
+
+
# Step 4: Run reprocessing (simulates app restart)
await reprocess_stuck_webhooks(AsyncSessionLocal, test_config)
@@ -307,15 +310,14 @@ class TestWebhookReprocessingNeverBlocksStartup:
session.add(endpoint)
await session.flush()
- # Create stuck webhook with INVALID data (missing required fields)
- stuck_webhook = WebhookRequest(
- webhook_endpoint_id=endpoint.id,
+ webhook_request = WebhookRequestData(
hotel_id="HOTEL123",
payload_json={"data": {"invalid": "data"}}, # Missing required fields
- status=WebhookStatus.PROCESSING,
- created_at=datetime.now(UTC),
- payload_hash="invalidhash" # Add a dummy payload_hash to avoid integrity error
+ status=WebhookStatus.PROCESSING
)
+ stuck_webhook = WebhookRequest(**webhook_request.model_dump())
+
+
session.add(stuck_webhook) ## Cannot add the stuck webhook. Integrity Error payload_hash is missing
await session.commit()
From 877b2909f2086b7f660fc9c4b00791b3d37c2f66 Mon Sep 17 00:00:00 2001
From: Jonas Linter <{email_address}>
Date: Mon, 1 Dec 2025 11:12:22 +0100
Subject: [PATCH 7/8] Fixed room upsert logic
---
src/alpine_bits_python/conversion_service.py | 35 +-
tests/helpers/README.md | 197 ++++++++++
tests/helpers/__init__.py | 13 +
tests/helpers/xml_builders.py | 392 +++++++++++++++++++
tests/test_conversion_service.py | 341 +++++++++++++++-
tests/test_xml_builders.py | 327 ++++++++++++++++
6 files changed, 1293 insertions(+), 12 deletions(-)
create mode 100644 tests/helpers/README.md
create mode 100644 tests/helpers/__init__.py
create mode 100644 tests/helpers/xml_builders.py
create mode 100644 tests/test_xml_builders.py
diff --git a/src/alpine_bits_python/conversion_service.py b/src/alpine_bits_python/conversion_service.py
index f0cec19..679d3e7 100644
--- a/src/alpine_bits_python/conversion_service.py
+++ b/src/alpine_bits_python/conversion_service.py
@@ -731,17 +731,10 @@ class ConversionService:
# Flush to ensure conversion has an ID before creating room reservations
await session.flush()
- # Batch-load existing room reservations to avoid N+1 queries
- room_numbers = [
- rm.get("roomNumber") for rm in room_reservations.findall("roomReservation")
- ]
- pms_hotel_reservation_ids = [
- f"{pms_reservation_id}_{room_num}" for room_num in room_numbers
- ]
-
+ # Fetch ALL existing rooms for this conversion (not just the ones in current XML)
existing_rooms_result = await session.execute(
select(ConversionRoom).where(
- ConversionRoom.pms_hotel_reservation_id.in_(pms_hotel_reservation_ids)
+ ConversionRoom.conversion_id == conversion.id
)
)
existing_rooms = {
@@ -749,6 +742,9 @@ class ConversionService:
for room in existing_rooms_result.scalars().all()
}
+ # Track which room IDs are present in the current XML
+ current_pms_hotel_reservation_ids = set()
+
# Process room reservations
for room_reservation in room_reservations.findall("roomReservation"):
# Extract room reservation details
@@ -786,6 +782,9 @@ class ConversionService:
# This allows updating the same room reservation if it appears again
pms_hotel_reservation_id = f"{pms_reservation_id}_{room_number}"
+ # Track this room as present in current XML
+ current_pms_hotel_reservation_ids.add(pms_hotel_reservation_id)
+
# Process daily sales and extract total revenue
daily_sales_elem = room_reservation.find("dailySales")
daily_sales_list = []
@@ -880,6 +879,24 @@ class ConversionService:
num_adults,
)
+ # Delete room entries that are no longer present in the current XML
+ # This handles cases where a reservation is updated and room numbers change
+ rooms_to_delete = [
+ room
+ for pms_id, room in existing_rooms.items()
+ if pms_id not in current_pms_hotel_reservation_ids
+ ]
+
+ if rooms_to_delete:
+ for room in rooms_to_delete:
+ await session.delete(room)
+ _LOGGER.debug(
+ "Deleted room reservation %s (pms_id=%s, room=%s) - no longer in current XML",
+ room.id,
+ room.pms_hotel_reservation_id,
+ room.room_number,
+ )
+
return stats
diff --git a/tests/helpers/README.md b/tests/helpers/README.md
new file mode 100644
index 0000000..5943dd2
--- /dev/null
+++ b/tests/helpers/README.md
@@ -0,0 +1,197 @@
+# Test Helpers
+
+This directory contains helper utilities for creating test data.
+
+## XML Builders
+
+The `xml_builders` module provides convenient builder classes for creating reservation XML structures used in conversion service tests.
+
+### Quick Start
+
+```python
+from tests.helpers import ReservationXMLBuilder
+
+# Create a simple reservation
+xml = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12345",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ )
+ .set_guest(
+ guest_id="guest_001",
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ revenue_logis_per_day=150.0, # Fixed revenue per night
+ )
+ .build_xml()
+)
+```
+
+### Features
+
+#### ReservationXMLBuilder
+
+The main builder class for creating reservation XML structures.
+
+**Key Features:**
+- Fluent API for method chaining
+- Automatic daily sales generation from arrival to departure
+- Convenient revenue-per-day specification (no need to manually create each dailySale)
+- Support for advertising campaign data
+- Guest information with optional fields
+
+**Example - Multi-room reservation:**
+
+```python
+xml = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12345",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ )
+ .set_guest(
+ guest_id="guest_001",
+ first_name="Jane",
+ last_name="Smith",
+ email="jane@example.com",
+ country_code="US",
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ room_number="101",
+ room_type="DZV",
+ revenue_logis_per_day=150.0,
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ room_number="102",
+ room_type="DZM",
+ revenue_logis_per_day=200.0,
+ )
+ .build_xml()
+)
+```
+
+#### Daily Sales Generation
+
+The builder automatically generates `` entries for each day from arrival to departure (inclusive).
+
+- **Days before departure**: Include `revenueTotal` and `revenueLogis` attributes
+- **Departure day**: No revenue attributes (just the date)
+
+**Example:**
+```python
+# A 3-night stay (Dec 1-4)
+.add_room(
+ arrival="2025-12-01",
+ departure="2025-12-04",
+ revenue_logis_per_day=160.0,
+)
+```
+
+Generates:
+```xml
+
+
+
+
+
+
+```
+
+#### MultiReservationXMLBuilder
+
+For creating XML documents with multiple reservations:
+
+```python
+from tests.helpers import ReservationXMLBuilder, MultiReservationXMLBuilder
+
+multi_builder = MultiReservationXMLBuilder()
+
+# Add first reservation
+res1 = (
+ ReservationXMLBuilder(...)
+ .set_guest(...)
+ .add_room(...)
+)
+multi_builder.add_reservation(res1)
+
+# Add second reservation
+res2 = (
+ ReservationXMLBuilder(...)
+ .set_guest(...)
+ .add_room(...)
+)
+multi_builder.add_reservation(res2)
+
+xml = multi_builder.build_xml()
+```
+
+#### RoomReservationBuilder
+
+Low-level builder for creating individual room reservations. Usually you'll use `ReservationXMLBuilder.add_room()` instead, but this is available for advanced use cases.
+
+```python
+from tests.helpers import RoomReservationBuilder
+
+room_builder = RoomReservationBuilder(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ room_type="DZV",
+ room_number="101",
+ revenue_logis_per_day=150.0,
+)
+
+# Get the XML element (not a string)
+room_elem = room_builder.build()
+```
+
+### Common Parameters
+
+**ReservationXMLBuilder:**
+- `hotel_id` - Hotel ID (required)
+- `reservation_id` - Reservation ID (required)
+- `reservation_number` - Reservation number (required)
+- `reservation_date` - Reservation date YYYY-MM-DD (required)
+- `creation_time` - Creation timestamp (optional, defaults to reservation_date + T00:00:00)
+- `advertising_medium` - Advertising medium (optional)
+- `advertising_partner` - Advertising partner (optional)
+- `advertising_campagne` - Advertising campaign (optional)
+
+**set_guest() parameters:**
+- `guest_id` - Guest ID (required)
+- `first_name` - First name (required)
+- `last_name` - Last name (required)
+- `email` - Email address (required)
+- `language` - Language code (default: "en")
+- `gender` - Gender (optional)
+- `country_code` - Country code (optional)
+- `country` - Country name (optional)
+
+**add_room() parameters:**
+- `arrival` - Arrival date YYYY-MM-DD (required)
+- `departure` - Departure date YYYY-MM-DD (required)
+- `room_type` - Room type code (default: "DZV")
+- `room_number` - Room number (default: "101")
+- `status` - Reservation status (default: "reserved")
+- `adults` - Number of adults (default: 2)
+- `children` - Number of children (default: 0)
+- `infants` - Number of infants (default: 0)
+- `rate_plan_code` - Rate plan code (default: "STANDARD")
+- `revenue_logis_per_day` - Fixed revenue per night (optional, generates daily sales)
+- `revenue_total_per_day` - Total revenue per night (optional, defaults to revenue_logis_per_day)
+
+### See Also
+
+- [tests/test_xml_builders.py](../test_xml_builders.py) - Unit tests demonstrating all features
+- [tests/test_conversion_service.py](../test_conversion_service.py) - Integration examples (TestXMLBuilderUsage class)
diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py
new file mode 100644
index 0000000..0b8d7fd
--- /dev/null
+++ b/tests/helpers/__init__.py
@@ -0,0 +1,13 @@
+"""Test helper utilities for creating test data."""
+
+from .xml_builders import (
+ ReservationXMLBuilder,
+ MultiReservationXMLBuilder,
+ RoomReservationBuilder,
+)
+
+__all__ = [
+ "ReservationXMLBuilder",
+ "MultiReservationXMLBuilder",
+ "RoomReservationBuilder",
+]
diff --git a/tests/helpers/xml_builders.py b/tests/helpers/xml_builders.py
new file mode 100644
index 0000000..0bf622c
--- /dev/null
+++ b/tests/helpers/xml_builders.py
@@ -0,0 +1,392 @@
+"""XML builder helpers for creating test reservation data.
+
+This module provides convenient builder classes for generating reservation XML
+structures used in conversion service tests.
+"""
+
+from datetime import datetime, timedelta
+from typing import Optional
+from xml.etree import ElementTree as ET
+
+
+class RoomReservationBuilder:
+ """Builder for creating roomReservation XML elements with daily sales."""
+
+ def __init__(
+ self,
+ arrival: str,
+ departure: str,
+ room_type: str = "DZV",
+ room_number: str = "101",
+ status: str = "reserved",
+ adults: int = 2,
+ children: int = 0,
+ infants: int = 0,
+ rate_plan_code: str = "STANDARD",
+ connected_room_type: str = "0",
+ revenue_logis_per_day: Optional[float] = None,
+ revenue_total_per_day: Optional[float] = None,
+ ):
+ """Initialize room reservation builder.
+
+ Args:
+ arrival: Arrival date in YYYY-MM-DD format
+ departure: Departure date in YYYY-MM-DD format
+ room_type: Room type code
+ room_number: Room number
+ status: Reservation status (reserved, request, confirmed, etc.)
+ adults: Number of adults
+ children: Number of children
+ infants: Number of infants
+ rate_plan_code: Rate plan code
+ connected_room_type: Connected room type code
+ revenue_logis_per_day: Revenue per day (if None, no revenue attributes)
+ revenue_total_per_day: Total revenue per day (defaults to revenue_logis_per_day)
+ """
+ self.arrival = arrival
+ self.departure = departure
+ self.room_type = room_type
+ self.room_number = room_number
+ self.status = status
+ self.adults = adults
+ self.children = children
+ self.infants = infants
+ self.rate_plan_code = rate_plan_code
+ self.connected_room_type = connected_room_type
+ self.revenue_logis_per_day = revenue_logis_per_day
+ self.revenue_total_per_day = revenue_total_per_day or revenue_logis_per_day
+
+ def build(self) -> ET.Element:
+ """Build the roomReservation XML element with daily sales.
+
+ Returns:
+ XML Element for the room reservation
+ """
+ room_attrs = {
+ "arrival": self.arrival,
+ "departure": self.departure,
+ "status": self.status,
+ "roomType": self.room_type,
+ "roomNumber": self.room_number,
+ "adults": str(self.adults),
+ "ratePlanCode": self.rate_plan_code,
+ "connectedRoomType": self.connected_room_type,
+ }
+
+ if self.children > 0:
+ room_attrs["children"] = str(self.children)
+ if self.infants > 0:
+ room_attrs["infants"] = str(self.infants)
+
+ room_elem = ET.Element("roomReservation", room_attrs)
+
+ # Create dailySales element
+ daily_sales_elem = ET.SubElement(room_elem, "dailySales")
+
+ # Generate daily sale entries from arrival to departure (inclusive of departure for the no-revenue entry)
+ arrival_date = datetime.strptime(self.arrival, "%Y-%m-%d")
+ departure_date = datetime.strptime(self.departure, "%Y-%m-%d")
+
+ current_date = arrival_date
+ while current_date <= departure_date:
+ date_str = current_date.strftime("%Y-%m-%d")
+ daily_sale_attrs = {"date": date_str}
+
+ # Add revenue attributes for all days except departure day
+ if current_date < departure_date and self.revenue_logis_per_day is not None:
+ daily_sale_attrs["revenueTotal"] = str(self.revenue_total_per_day)
+ daily_sale_attrs["revenueLogis"] = str(self.revenue_logis_per_day)
+
+ ET.SubElement(daily_sales_elem, "dailySale", daily_sale_attrs)
+ current_date += timedelta(days=1)
+
+ return room_elem
+
+
+class ReservationXMLBuilder:
+ """Builder for creating complete reservation XML structures for testing.
+
+ This builder provides a fluent interface for constructing reservation XML
+ that matches the format expected by the ConversionService.
+
+ Example usage:
+ builder = ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12345",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14"
+ )
+ builder.set_guest(
+ guest_id="guest_001",
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com"
+ )
+ builder.add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ revenue_logis_per_day=150.0
+ )
+ xml_string = builder.build_xml()
+ """
+
+ def __init__(
+ self,
+ hotel_id: str,
+ reservation_id: str,
+ reservation_number: str,
+ reservation_date: str,
+ creation_time: Optional[str] = None,
+ reservation_type: str = "reservation",
+ advertising_medium: Optional[str] = None,
+ advertising_partner: Optional[str] = None,
+ advertising_campagne: Optional[str] = None,
+ ):
+ """Initialize reservation builder.
+
+ Args:
+ hotel_id: Hotel ID
+ reservation_id: Reservation ID
+ reservation_number: Reservation number
+ reservation_date: Reservation date in YYYY-MM-DD format
+ creation_time: Creation timestamp (defaults to reservation_date + T00:00:00)
+ reservation_type: Type of reservation (reservation, request, etc.)
+ advertising_medium: Advertising medium
+ advertising_partner: Advertising partner
+ advertising_campagne: Advertising campaign
+ """
+ self.hotel_id = hotel_id
+ self.reservation_id = reservation_id
+ self.reservation_number = reservation_number
+ self.reservation_date = reservation_date
+ self.creation_time = creation_time or f"{reservation_date}T00:00:00"
+ self.reservation_type = reservation_type
+ self.advertising_medium = advertising_medium
+ self.advertising_partner = advertising_partner
+ self.advertising_campagne = advertising_campagne
+
+ self.guest_data: Optional[dict] = None
+ self.rooms: list[RoomReservationBuilder] = []
+
+ def set_guest(
+ self,
+ guest_id: str,
+ first_name: str,
+ last_name: str,
+ email: str,
+ language: str = "en",
+ gender: Optional[str] = None,
+ country_code: Optional[str] = None,
+ country: Optional[str] = None,
+ ) -> "ReservationXMLBuilder":
+ """Set guest information for the reservation.
+
+ Args:
+ guest_id: Guest ID
+ first_name: Guest first name
+ last_name: Guest last name
+ email: Guest email
+ language: Guest language code
+ gender: Guest gender
+ country_code: Guest country code
+ country: Guest country name
+
+ Returns:
+ Self for method chaining
+ """
+ self.guest_data = {
+ "id": guest_id,
+ "firstName": first_name,
+ "lastName": last_name,
+ "email": email,
+ "language": language,
+ }
+ if gender:
+ self.guest_data["gender"] = gender
+ if country_code:
+ self.guest_data["countryCode"] = country_code
+ if country:
+ self.guest_data["country"] = country
+
+ return self
+
+ def add_room(
+ self,
+ arrival: str,
+ departure: str,
+ room_type: str = "DZV",
+ room_number: str = "101",
+ status: str = "reserved",
+ adults: int = 2,
+ children: int = 0,
+ infants: int = 0,
+ rate_plan_code: str = "STANDARD",
+ connected_room_type: str = "0",
+ revenue_logis_per_day: Optional[float] = None,
+ revenue_total_per_day: Optional[float] = None,
+ ) -> "ReservationXMLBuilder":
+ """Add a room reservation with convenient daily sales generation.
+
+ Args:
+ arrival: Arrival date in YYYY-MM-DD format
+ departure: Departure date in YYYY-MM-DD format
+ room_type: Room type code
+ room_number: Room number
+ status: Reservation status
+ adults: Number of adults
+ children: Number of children
+ infants: Number of infants
+ rate_plan_code: Rate plan code
+ connected_room_type: Connected room type
+ revenue_logis_per_day: Fixed revenue per day (auto-generates dailySale entries)
+ revenue_total_per_day: Total revenue per day (defaults to revenue_logis_per_day)
+
+ Returns:
+ Self for method chaining
+ """
+ room_builder = RoomReservationBuilder(
+ arrival=arrival,
+ departure=departure,
+ room_type=room_type,
+ room_number=room_number,
+ status=status,
+ adults=adults,
+ children=children,
+ infants=infants,
+ rate_plan_code=rate_plan_code,
+ connected_room_type=connected_room_type,
+ revenue_logis_per_day=revenue_logis_per_day,
+ revenue_total_per_day=revenue_total_per_day,
+ )
+ self.rooms.append(room_builder)
+ return self
+
+ def add_room_builder(
+ self, room_builder: RoomReservationBuilder
+ ) -> "ReservationXMLBuilder":
+ """Add a pre-configured room builder.
+
+ Args:
+ room_builder: RoomReservationBuilder instance
+
+ Returns:
+ Self for method chaining
+ """
+ self.rooms.append(room_builder)
+ return self
+
+ def build(self) -> ET.Element:
+ """Build the reservation XML element.
+
+ Returns:
+ XML Element for the reservation
+ """
+ reservation_attrs = {
+ "hotelID": self.hotel_id,
+ "id": self.reservation_id,
+ "number": self.reservation_number,
+ "date": self.reservation_date,
+ "creationTime": self.creation_time,
+ "type": self.reservation_type,
+ }
+
+ if self.advertising_medium:
+ reservation_attrs["advertisingMedium"] = self.advertising_medium
+ if self.advertising_partner:
+ reservation_attrs["advertisingPartner"] = self.advertising_partner
+ if self.advertising_campagne:
+ reservation_attrs["advertisingCampagne"] = self.advertising_campagne
+
+ reservation_elem = ET.Element("reservation", reservation_attrs)
+
+ # Add guest element
+ if self.guest_data:
+ ET.SubElement(reservation_elem, "guest", self.guest_data)
+
+ # Add roomReservations
+ if self.rooms:
+ room_reservations_elem = ET.SubElement(
+ reservation_elem, "roomReservations"
+ )
+ for room_builder in self.rooms:
+ room_elem = room_builder.build()
+ room_reservations_elem.append(room_elem)
+
+ return reservation_elem
+
+ def build_xml(self, include_xml_declaration: bool = True) -> str:
+ """Build the complete XML string for this reservation.
+
+ Args:
+ include_xml_declaration: Whether to include declaration
+
+ Returns:
+ XML string
+ """
+ reservation_elem = self.build()
+
+ # Wrap in root element
+ root = ET.Element("reservations")
+ root.append(reservation_elem)
+
+ xml_str = ET.tostring(root, encoding="unicode")
+
+ if include_xml_declaration:
+ xml_str = '\n' + xml_str
+
+ return xml_str
+
+
+class MultiReservationXMLBuilder:
+ """Builder for creating XML documents with multiple reservations.
+
+ Example:
+ builder = MultiReservationXMLBuilder()
+ builder.add_reservation(
+ ReservationXMLBuilder(...).set_guest(...).add_room(...)
+ )
+ builder.add_reservation(
+ ReservationXMLBuilder(...).set_guest(...).add_room(...)
+ )
+ xml_string = builder.build_xml()
+ """
+
+ def __init__(self):
+ """Initialize multi-reservation builder."""
+ self.reservations: list[ReservationXMLBuilder] = []
+
+ def add_reservation(
+ self, reservation_builder: ReservationXMLBuilder
+ ) -> "MultiReservationXMLBuilder":
+ """Add a reservation to the document.
+
+ Args:
+ reservation_builder: ReservationXMLBuilder instance
+
+ Returns:
+ Self for method chaining
+ """
+ self.reservations.append(reservation_builder)
+ return self
+
+ def build_xml(self, include_xml_declaration: bool = True) -> str:
+ """Build the complete XML string with all reservations.
+
+ Args:
+ include_xml_declaration: Whether to include declaration
+
+ Returns:
+ XML string with multiple reservations
+ """
+ root = ET.Element("reservations")
+
+ for reservation_builder in self.reservations:
+ reservation_elem = reservation_builder.build()
+ root.append(reservation_elem)
+
+ xml_str = ET.tostring(root, encoding="unicode")
+
+ if include_xml_declaration:
+ xml_str = '\n' + xml_str
+
+ return xml_str
diff --git a/tests/test_conversion_service.py b/tests/test_conversion_service.py
index 8ee22ba..094b24e 100644
--- a/tests/test_conversion_service.py
+++ b/tests/test_conversion_service.py
@@ -340,13 +340,348 @@ class TestConversionServiceWithImportedData:
assert stats["total_daily_sales"] == 0
assert stats["errors"] == 0
+ @pytest.mark.asyncio
+ async def test_duplicate_reservations(self, test_db_session):
+ """Test that room entries are correctly updated when reservation status changes.
+
+ This test detects a bug where ConversionRoom records are not properly upserted
+ when the same reservation is processed multiple times with different room numbers.
+
+ Scenario:
+ 1. Process reservation with status='request', no revenue, room_number='101'
+ 2. Process reservation with status='reservation', with revenue, room_number='102'
+ 3. Swap: Process same reservations but reversed - first one now has status='reservation'
+ with room_number='201', second has status='request' with room_number='202'
+ 4. The old room entries (101, 102) should no longer exist in the database
+ """
+ from tests.helpers import ReservationXMLBuilder, MultiReservationXMLBuilder
+
+ # First batch: Process two reservations
+ multi_builder1 = MultiReservationXMLBuilder()
+
+ # Reservation 1: Request status, no revenue, room 101
+ res1_v1 = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="res_001",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ reservation_type="request",
+ )
+ .set_guest(
+ guest_id="guest_001",
+ first_name="Alice",
+ last_name="Johnson",
+ email="alice@example.com",
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-03",
+ room_number="101",
+ status="request",
+ # No revenue
+ )
+ )
+ multi_builder1.add_reservation(res1_v1)
+
+ # Reservation 2: Reservation status, with revenue, room 102
+ res2_v1 = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="res_002",
+ reservation_number="RES-002",
+ reservation_date="2025-11-15",
+ reservation_type="reservation",
+ )
+ .set_guest(
+ guest_id="guest_002",
+ first_name="Bob",
+ last_name="Smith",
+ email="bob@example.com",
+ )
+ .add_room(
+ arrival="2025-12-10",
+ departure="2025-12-12",
+ room_number="102",
+ status="reserved",
+ revenue_logis_per_day=150.0,
+ )
+ )
+ multi_builder1.add_reservation(res2_v1)
+
+ xml_content1 = multi_builder1.build_xml()
+
+ # Process first batch
+ service = ConversionService(test_db_session)
+ stats1 = await service.process_conversion_xml(xml_content1)
+
+ assert stats1["total_reservations"] == 2
+
+ # Verify rooms exist in database
+ result = await test_db_session.execute(
+ select(ConversionRoom).where(ConversionRoom.room_number == "101")
+ )
+ room_101 = result.scalar_one_or_none()
+ assert room_101 is not None, "Room 101 should exist after first processing"
+
+ result = await test_db_session.execute(
+ select(ConversionRoom).where(ConversionRoom.room_number == "102")
+ )
+ room_102 = result.scalar_one_or_none()
+ assert room_102 is not None, "Room 102 should exist after first processing"
+
+ # Second batch: Swap the reservations and change room numbers
+ multi_builder2 = MultiReservationXMLBuilder()
+
+ # Reservation 1: NOW has reservation status, with revenue, room 201 (changed from 101)
+ res1_v2 = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="res_001", # Same ID
+ reservation_number="RES-001", # Same number
+ reservation_date="2025-11-14",
+ reservation_type="reservation", # Changed from request
+ )
+ .set_guest(
+ guest_id="guest_001",
+ first_name="Alice",
+ last_name="Johnson",
+ email="alice@example.com",
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-03",
+ room_number="201", # Changed from 101
+ status="reserved",
+ revenue_logis_per_day=200.0, # Now has revenue
+ )
+ )
+ multi_builder2.add_reservation(res1_v2)
+
+ # Reservation 2: NOW has request status, no revenue, room 202 (changed from 102)
+ res2_v2 = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="res_002", # Same ID
+ reservation_number="RES-002", # Same number
+ reservation_date="2025-11-15",
+ reservation_type="request", # Changed from reservation
+ )
+ .set_guest(
+ guest_id="guest_002",
+ first_name="Bob",
+ last_name="Smith",
+ email="bob@example.com",
+ )
+ .add_room(
+ arrival="2025-12-10",
+ departure="2025-12-12",
+ room_number="202", # Changed from 102
+ status="request",
+ # No revenue anymore
+ )
+ )
+ multi_builder2.add_reservation(res2_v2)
+
+ xml_content2 = multi_builder2.build_xml()
+
+ # Process second batch
+ stats2 = await service.process_conversion_xml(xml_content2)
+
+ assert stats2["total_reservations"] == 2
+
+ # BUG DETECTION: Old room entries (101, 102) should NOT exist anymore
+ # They should have been replaced by new room entries (201, 202)
+
+ result = await test_db_session.execute(
+ select(ConversionRoom).where(ConversionRoom.room_number == "101")
+ )
+ room_101_after = result.scalar_one_or_none()
+ assert room_101_after is None, (
+ "BUG: Room 101 should no longer exist after reprocessing with room 201. "
+ "Old room entries are not being removed when reservation is updated."
+ )
+
+ result = await test_db_session.execute(
+ select(ConversionRoom).where(ConversionRoom.room_number == "102")
+ )
+ room_102_after = result.scalar_one_or_none()
+ assert room_102_after is None, (
+ "BUG: Room 102 should no longer exist after reprocessing with room 202. "
+ "Old room entries are not being removed when reservation is updated."
+ )
+
+ # New room entries should exist
+ result = await test_db_session.execute(
+ select(ConversionRoom).where(ConversionRoom.room_number == "201")
+ )
+ room_201 = result.scalar_one_or_none()
+ assert room_201 is not None, "Room 201 should exist after second processing"
+
+ result = await test_db_session.execute(
+ select(ConversionRoom).where(ConversionRoom.room_number == "202")
+ )
+ room_202 = result.scalar_one_or_none()
+ assert room_202 is not None, "Room 202 should exist after second processing"
+
+ # Verify we only have 2 conversion room records total (not 4)
+ result = await test_db_session.execute(select(ConversionRoom))
+ all_rooms = result.scalars().all()
+ assert len(all_rooms) == 2, (
+ f"BUG: Expected 2 conversion rooms total, but found {len(all_rooms)}. "
+ f"Old room entries are not being deleted. Room numbers: {[r.room_number for r in all_rooms]}"
+ )
+
+
+
+class TestXMLBuilderUsage:
+ """Demonstrate usage of XML builder helpers for creating test data."""
+
+ @pytest.mark.asyncio
+ async def test_using_xml_builder_for_simple_reservation(self, test_db_session):
+ """Example: Create a simple reservation using the XML builder helper."""
+ from tests.helpers import ReservationXMLBuilder
+
+ # Build a reservation with convenient fluent API
+ xml_content = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="test_123",
+ reservation_number="RES-123",
+ reservation_date="2025-11-14",
+ )
+ .set_guest(
+ guest_id="guest_001",
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ country_code="US",
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ room_type="DZV",
+ room_number="101",
+ revenue_logis_per_day=150.0,
+ adults=2
+ )
+ .build_xml()
+ )
+
+ # Process the XML
+ service = ConversionService(test_db_session)
+ stats = await service.process_conversion_xml(xml_content)
+
+ assert stats["total_reservations"] == 1
+ assert stats["total_daily_sales"] == 5 # 4 nights + departure day
+
+ @pytest.mark.asyncio
+ async def test_using_xml_builder_for_multi_room_reservation(
+ self, test_db_session
+ ):
+ """Example: Create a reservation with multiple rooms."""
+ from tests.helpers import ReservationXMLBuilder
+
+ xml_content = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="test_456",
+ reservation_number="RES-456",
+ reservation_date="2025-11-14",
+ )
+ .set_guest(
+ guest_id="guest_002",
+ first_name="Jane",
+ last_name="Smith",
+ email="jane@example.com",
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ room_number="101",
+ revenue_logis_per_day=150.0,
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ room_number="102",
+ revenue_logis_per_day=200.0,
+ )
+ .build_xml()
+ )
+
+ service = ConversionService(test_db_session)
+ stats = await service.process_conversion_xml(xml_content)
+
+ assert stats["total_reservations"] == 1
+ # 2 rooms × 5 daily sales each = 10 total
+ assert stats["total_daily_sales"] == 10
+
+ @pytest.mark.asyncio
+ async def test_using_multi_reservation_builder(self, test_db_session):
+ """Example: Create multiple reservations in one XML document."""
+ from tests.helpers import ReservationXMLBuilder, MultiReservationXMLBuilder
+
+ multi_builder = MultiReservationXMLBuilder()
+
+ # Add first reservation
+ res1 = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="test_001",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ )
+ .set_guest(
+ guest_id="guest_001",
+ first_name="Alice",
+ last_name="Johnson",
+ email="alice@example.com",
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-03",
+ revenue_logis_per_day=100.0,
+ )
+ )
+ multi_builder.add_reservation(res1)
+
+ # Add second reservation
+ res2 = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="test_002",
+ reservation_number="RES-002",
+ reservation_date="2025-11-15",
+ )
+ .set_guest(
+ guest_id="guest_002",
+ first_name="Bob",
+ last_name="Williams",
+ email="bob@example.com",
+ )
+ .add_room(
+ arrival="2025-12-10",
+ departure="2025-12-12",
+ revenue_logis_per_day=150.0,
+ )
+ )
+ multi_builder.add_reservation(res2)
+
+ xml_content = multi_builder.build_xml()
+
+ # Process the XML
+ service = ConversionService(test_db_session)
+ stats = await service.process_conversion_xml(xml_content)
+
+ assert stats["total_reservations"] == 2
+ # Res1: 3 days (2 nights), Res2: 3 days (2 nights) = 6 total
+ assert stats["total_daily_sales"] == 6
+
class TestHashedMatchingLogic:
"""Test the hashed matching logic used in ConversionService."""
-
-
-
@pytest.mark.asyncio
async def test_conversion_guest_hashed_fields_are_populated(
self, test_db_session
diff --git a/tests/test_xml_builders.py b/tests/test_xml_builders.py
new file mode 100644
index 0000000..984b478
--- /dev/null
+++ b/tests/test_xml_builders.py
@@ -0,0 +1,327 @@
+"""Tests for XML builder helpers."""
+
+import pytest
+from xml.etree import ElementTree as ET
+
+from tests.helpers.xml_builders import (
+ ReservationXMLBuilder,
+ MultiReservationXMLBuilder,
+ RoomReservationBuilder,
+)
+
+
+class TestRoomReservationBuilder:
+ """Test RoomReservationBuilder functionality."""
+
+ def test_basic_room_without_revenue(self):
+ """Test creating a basic room reservation without revenue."""
+ builder = RoomReservationBuilder(
+ arrival="2025-12-01",
+ departure="2025-12-03",
+ room_type="DZV",
+ room_number="101",
+ )
+
+ elem = builder.build()
+
+ assert elem.tag == "roomReservation"
+ assert elem.get("arrival") == "2025-12-01"
+ assert elem.get("departure") == "2025-12-03"
+ assert elem.get("roomType") == "DZV"
+ assert elem.get("roomNumber") == "101"
+
+ # Check daily sales - should have 3 entries (12-01, 12-02, 12-03)
+ daily_sales = elem.find("dailySales")
+ assert daily_sales is not None
+ daily_sale_elements = daily_sales.findall("dailySale")
+ assert len(daily_sale_elements) == 3
+
+ # First two should have no revenue attributes
+ assert daily_sale_elements[0].get("revenueTotal") is None
+ assert daily_sale_elements[0].get("revenueLogis") is None
+
+ def test_room_with_revenue(self):
+ """Test creating a room with revenue per day."""
+ builder = RoomReservationBuilder(
+ arrival="2025-12-01",
+ departure="2025-12-03",
+ room_type="DZV",
+ room_number="101",
+ revenue_logis_per_day=150.0,
+ )
+
+ elem = builder.build()
+ daily_sales = elem.find("dailySales")
+ daily_sale_elements = daily_sales.findall("dailySale")
+
+ # Should have 3 entries total
+ assert len(daily_sale_elements) == 3
+
+ # First two days should have revenue
+ assert daily_sale_elements[0].get("revenueTotal") == "150.0"
+ assert daily_sale_elements[0].get("revenueLogis") == "150.0"
+ assert daily_sale_elements[1].get("revenueTotal") == "150.0"
+ assert daily_sale_elements[1].get("revenueLogis") == "150.0"
+
+ # Departure day should have no revenue
+ assert daily_sale_elements[2].get("revenueTotal") is None
+ assert daily_sale_elements[2].get("revenueLogis") is None
+
+ def test_room_with_children_and_infants(self):
+ """Test room with children and infants attributes."""
+ builder = RoomReservationBuilder(
+ arrival="2025-12-01",
+ departure="2025-12-02",
+ adults=2,
+ children=1,
+ infants=1,
+ )
+
+ elem = builder.build()
+ assert elem.get("adults") == "2"
+ assert elem.get("children") == "1"
+ assert elem.get("infants") == "1"
+
+
+class TestReservationXMLBuilder:
+ """Test ReservationXMLBuilder functionality."""
+
+ def test_basic_reservation(self):
+ """Test creating a basic reservation with one room."""
+ builder = ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12345",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ )
+ builder.set_guest(
+ guest_id="guest_001",
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ )
+ builder.add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ revenue_logis_per_day=150.0,
+ )
+
+ xml_string = builder.build_xml()
+
+ # Parse and verify structure
+ root = ET.fromstring(xml_string)
+ assert root.tag == "reservations"
+
+ reservation = root.find("reservation")
+ assert reservation is not None
+ assert reservation.get("hotelID") == "39054_001"
+ assert reservation.get("id") == "12345"
+ assert reservation.get("number") == "RES-001"
+
+ guest = reservation.find("guest")
+ assert guest is not None
+ assert guest.get("firstName") == "John"
+ assert guest.get("lastName") == "Doe"
+ assert guest.get("email") == "john@example.com"
+
+ room_reservations = reservation.find("roomReservations")
+ assert room_reservations is not None
+ rooms = room_reservations.findall("roomReservation")
+ assert len(rooms) == 1
+
+ def test_reservation_with_multiple_rooms(self):
+ """Test reservation with multiple rooms."""
+ builder = ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12345",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ )
+ builder.set_guest(
+ guest_id="guest_001",
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ )
+ builder.add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ room_number="101",
+ revenue_logis_per_day=150.0,
+ )
+ builder.add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ room_number="102",
+ revenue_logis_per_day=200.0,
+ )
+
+ xml_string = builder.build_xml()
+ root = ET.fromstring(xml_string)
+
+ reservation = root.find("reservation")
+ room_reservations = reservation.find("roomReservations")
+ rooms = room_reservations.findall("roomReservation")
+
+ assert len(rooms) == 2
+ assert rooms[0].get("roomNumber") == "101"
+ assert rooms[1].get("roomNumber") == "102"
+
+ def test_reservation_with_advertising_data(self):
+ """Test reservation with advertising campaign data."""
+ builder = ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12345",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ advertising_medium="99TALES",
+ advertising_partner="google",
+ advertising_campagne="EAIaIQobChMI...",
+ )
+ builder.set_guest(
+ guest_id="guest_001",
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ )
+ builder.add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ )
+
+ xml_string = builder.build_xml()
+ root = ET.fromstring(xml_string)
+
+ reservation = root.find("reservation")
+ assert reservation.get("advertisingMedium") == "99TALES"
+ assert reservation.get("advertisingPartner") == "google"
+ assert reservation.get("advertisingCampagne") == "EAIaIQobChMI..."
+
+
+class TestMultiReservationXMLBuilder:
+ """Test MultiReservationXMLBuilder functionality."""
+
+ def test_multiple_reservations(self):
+ """Test creating XML with multiple reservations."""
+ multi_builder = MultiReservationXMLBuilder()
+
+ # Add first reservation
+ res1 = ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12345",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ )
+ res1.set_guest(
+ guest_id="guest_001",
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ )
+ res1.add_room(
+ arrival="2025-12-01",
+ departure="2025-12-03",
+ revenue_logis_per_day=150.0,
+ )
+ multi_builder.add_reservation(res1)
+
+ # Add second reservation
+ res2 = ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12346",
+ reservation_number="RES-002",
+ reservation_date="2025-11-15",
+ )
+ res2.set_guest(
+ guest_id="guest_002",
+ first_name="Jane",
+ last_name="Smith",
+ email="jane@example.com",
+ )
+ res2.add_room(
+ arrival="2025-12-10",
+ departure="2025-12-12",
+ revenue_logis_per_day=200.0,
+ )
+ multi_builder.add_reservation(res2)
+
+ xml_string = multi_builder.build_xml()
+ root = ET.fromstring(xml_string)
+
+ assert root.tag == "reservations"
+ reservations = root.findall("reservation")
+ assert len(reservations) == 2
+ assert reservations[0].get("id") == "12345"
+ assert reservations[1].get("id") == "12346"
+
+
+class TestConvenienceFeatures:
+ """Test convenience features for common test scenarios."""
+
+ def test_simple_one_liner_reservation(self):
+ """Test creating a simple reservation in a fluent style."""
+ xml = (
+ ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12345",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ )
+ .set_guest(
+ guest_id="guest_001",
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ )
+ .add_room(
+ arrival="2025-12-01",
+ departure="2025-12-05",
+ revenue_logis_per_day=160.0,
+ )
+ .build_xml()
+ )
+
+ assert '' in xml
+ assert 'hotelID="39054_001"' in xml
+ assert 'revenueLogis="160.0"' in xml
+
+ def test_revenue_calculation_for_multi_day_stay(self):
+ """Test that daily sales are correctly generated for multi-day stays."""
+ builder = ReservationXMLBuilder(
+ hotel_id="39054_001",
+ reservation_id="12345",
+ reservation_number="RES-001",
+ reservation_date="2025-11-14",
+ )
+ builder.set_guest(
+ guest_id="guest_001",
+ first_name="John",
+ last_name="Doe",
+ email="john@example.com",
+ )
+ # 7-day stay (June 25 - July 2, 7 nights)
+ builder.add_room(
+ arrival="2026-06-25",
+ departure="2026-07-02",
+ revenue_logis_per_day=160.0,
+ )
+
+ elem = builder.build()
+ room_reservations = elem.find("roomReservations")
+ room = room_reservations.find("roomReservation")
+ daily_sales = room.find("dailySales")
+ daily_sale_elements = daily_sales.findall("dailySale")
+
+ # Should have 8 daily sale entries (7 nights + departure day)
+ assert len(daily_sale_elements) == 8
+
+ # First 7 should have revenue
+ for i in range(7):
+ assert daily_sale_elements[i].get("revenueLogis") == "160.0"
+
+ # Departure day should not have revenue
+ assert daily_sale_elements[7].get("revenueLogis") is None
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
From d04218988dbeeeacc0991a40340a7ba51d87dfe8 Mon Sep 17 00:00:00 2001
From: Jonas Linter <{email_address}>
Date: Mon, 1 Dec 2025 11:14:31 +0100
Subject: [PATCH 8/8] Disabled free rooms action for now
---
src/alpine_bits_python/alpinebits_server.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py
index 677e695..91ee96c 100644
--- a/src/alpine_bits_python/alpinebits_server.py
+++ b/src/alpine_bits_python/alpinebits_server.py
@@ -826,4 +826,4 @@ class AlpineBitsServer:
# Ensure FreeRoomsAction is registered with ServerCapabilities discovery
-from .free_rooms_action import FreeRoomsAction # noqa: E402,F401
+#from .free_rooms_action import FreeRoomsAction # noqa: E402,F401 disable for now