From 16d12f5b62020741f266693e7af47feacab56496 Mon Sep 17 00:00:00 2001
From: Jonas Linter <{email_address}>
Date: Thu, 4 Dec 2025 16:14:40 +0100
Subject: [PATCH] Free rooms doesn't cause errors but further data verification
is necessary
---
src/alpine_bits_python/free_rooms_action.py | 31 ++++
tests/test_free_rooms_action.py | 163 ++++++++++++++++++++
2 files changed, 194 insertions(+)
diff --git a/src/alpine_bits_python/free_rooms_action.py b/src/alpine_bits_python/free_rooms_action.py
index 488e270..472671f 100644
--- a/src/alpine_bits_python/free_rooms_action.py
+++ b/src/alpine_bits_python/free_rooms_action.py
@@ -229,6 +229,16 @@ class FreeRoomsAction(AlpineBitsAction):
HttpStatusCode.BAD_REQUEST,
)
+ # Special case: CompleteSet with single empty Inventory element to reset all availability
+ if (
+ update_type == "CompleteSet"
+ and len(inventories) == 1
+ and inventories[0].status_application_control is None
+ and inventories[0].inv_counts is None
+ ):
+ # This is valid - it's a reset request
+ return
+
encountered_standard = False
has_categories = False # Tracks if we've seen category reports (no InvCode)
has_rooms = False # Tracks if we've seen individual room reports (with InvCode)
@@ -315,7 +325,10 @@ class FreeRoomsAction(AlpineBitsAction):
self._validate_request(request, update_type, enforce_closing_order=True)
# Only delete if validation passes
+ # Delete availability data for all FreeRooms-sourced inventory
await self._delete_existing_availability(session, hotel.hotel_id)
+ # Delete stale inventory items that are sourced from FreeRooms
+ await self._delete_existing_inventory(session, hotel.hotel_id)
# Process the validated request
await self._process_inventories(
@@ -343,11 +356,29 @@ class FreeRoomsAction(AlpineBitsAction):
session: AsyncSession,
hotel_id: str,
) -> None:
+ """Delete all room availability data for a hotel (regardless of source)."""
subquery = select(HotelInventory.id).where(HotelInventory.hotel_id == hotel_id)
await session.execute(
delete(RoomAvailability).where(RoomAvailability.inventory_id.in_(subquery))
)
+ async def _delete_existing_inventory(
+ self,
+ session: AsyncSession,
+ hotel_id: str,
+ ) -> None:
+ """Delete inventory items sourced from FreeRooms.
+
+ This preserves inventory items from other sources (e.g., HotelInventory endpoint)
+ as they are not managed by FreeRooms and should persist across CompleteSet updates.
+ """
+ await session.execute(
+ delete(HotelInventory).where(
+ HotelInventory.hotel_id == hotel_id,
+ HotelInventory.source == SOURCE_FREEROOMS,
+ )
+ )
+
async def _process_inventories(
self,
session: AsyncSession,
diff --git a/tests/test_free_rooms_action.py b/tests/test_free_rooms_action.py
index aadb5ed..0a4d943 100644
--- a/tests/test_free_rooms_action.py
+++ b/tests/test_free_rooms_action.py
@@ -560,3 +560,166 @@ async def test_closing_season_with_rooms_is_allowed(db_session: AsyncSession):
db_session,
)
assert response.status_code == HttpStatusCode.OK
+
+
+@pytest.mark.asyncio
+async def test_complete_set_with_single_empty_inventory_resets_all_availability(
+ db_session: AsyncSession,
+):
+ """Test the special case: CompleteSet with one empty Inventory element to reset all availability.
+
+ According to AlpineBits spec, to completely reset all room availability information for a hotel,
+ a client can send a CompleteSet request with just one empty Inventory element without any
+ attributes. This is the only exception to the rule that StatusApplicationControl is required.
+ """
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # First, add some availability data
+ initial_xml = build_complete_set_xml(
+ daily_inventory("2025-01-01", "2025-01-05", inv_type="DBL", count=10)
+ )
+ await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ initial_xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ # Verify data was created
+ rows_before = (await db_session.execute(select(RoomAvailability))).scalars().all()
+ assert len(rows_before) == 5
+ inventory_before = (await db_session.execute(select(HotelInventory))).scalars().all()
+ assert len(inventory_before) == 1
+ assert inventory_before[0].source == "FreeRooms"
+
+ # Now send the special reset request with empty Inventory element
+ reset_xml = build_complete_set_xml("")
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ reset_xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ # Should succeed
+ assert response.status_code == HttpStatusCode.OK
+
+ # All availability and FreeRooms-sourced inventory should be cleared
+ rows_after = (await db_session.execute(select(RoomAvailability))).scalars().all()
+ assert len(rows_after) == 0
+ inventory_after = (await db_session.execute(select(HotelInventory))).scalars().all()
+ assert len(inventory_after) == 0
+
+
+@pytest.mark.asyncio
+async def test_delta_with_empty_inventory_is_rejected(db_session: AsyncSession):
+ """Test that empty Inventory is only allowed for CompleteSet, not Delta."""
+ 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,
+ )
+
+ # Delta requests cannot use empty Inventory
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "StatusApplicationControl element is required" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_complete_set_with_multiple_empty_inventories_is_rejected(
+ db_session: AsyncSession,
+):
+ """Test that the empty Inventory exception only applies to a single empty Inventory."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # Multiple empty Inventory elements should not be allowed
+ xml = build_complete_set_xml("")
+
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ # Should fail because the special case only applies to a single empty Inventory
+ assert response.status_code == HttpStatusCode.BAD_REQUEST
+ assert "StatusApplicationControl element is required" in response.xml_content
+
+
+@pytest.mark.asyncio
+async def test_complete_set_preserves_inventory_from_other_sources(db_session: AsyncSession):
+ """Test that CompleteSet only deletes FreeRooms-sourced inventory, not inventory from other sources."""
+ await insert_test_hotel(db_session)
+ action = make_action()
+
+ # First, add some FreeRooms inventory
+ freerooms_xml = build_complete_set_xml(
+ daily_inventory("2025-01-01", "2025-01-05", inv_type="DBL", count=10)
+ )
+ await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ freerooms_xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ # Manually add inventory from another source (simulating HotelInventory endpoint)
+ other_inventory = HotelInventory(
+ hotel_id="TESTHOTEL",
+ inv_type_code="SGL",
+ inv_code=None,
+ source="HotelInventory",
+ first_seen=datetime.now(UTC),
+ last_updated=datetime.now(UTC),
+ )
+ db_session.add(other_inventory)
+ await db_session.commit()
+
+ # Verify both inventory items exist
+ inventory_before = (
+ await db_session.execute(select(HotelInventory).order_by(HotelInventory.source))
+ ).scalars().all()
+ assert len(inventory_before) == 2
+ assert inventory_before[0].source == "FreeRooms"
+ assert inventory_before[1].source == "HotelInventory"
+
+ # Send a new CompleteSet with different data
+ new_xml = build_complete_set_xml(
+ daily_inventory("2025-01-01", "2025-01-03", inv_type="TRIPLE", count=5)
+ )
+ response = await action.handle(
+ "OTA_HotelInvCountNotif:FreeRooms",
+ new_xml,
+ Version.V2024_10,
+ make_client_info(),
+ db_session,
+ )
+
+ assert response.status_code == HttpStatusCode.OK
+
+ # Check inventory: FreeRooms inventory should be replaced, but HotelInventory source should remain
+ inventory_after = (
+ await db_session.execute(select(HotelInventory).order_by(HotelInventory.source))
+ ).scalars().all()
+ assert len(inventory_after) == 2
+ # New FreeRooms inventory
+ assert inventory_after[0].source == "FreeRooms"
+ assert inventory_after[0].inv_type_code == "TRIPLE"
+ # Preserved HotelInventory source
+ assert inventory_after[1].source == "HotelInventory"
+ assert inventory_after[1].inv_type_code == "SGL"