merge_db_fixes_to_main #16

Merged
jonas merged 40 commits from merge_db_fixes_to_main into main 2025-12-09 11:37:21 +00:00
2 changed files with 194 additions and 0 deletions
Showing only changes of commit 16d12f5b62 - Show all commits

View File

@@ -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,

View File

@@ -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("<Inventory/>")
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("<Inventory/>")
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("<Inventory/><Inventory/>")
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"