Free rooms doesn't cause errors but further data verification is necessary
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user