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"