2 Commits

Author SHA1 Message Date
Jonas Linter
9bbcb4b971 FIxed date range overlap 2025-12-04 16:33:11 +01:00
Jonas Linter
9ac5f38b55 Free rooms doesn't cause errors but further data verification is necessary 2025-12-04 16:14:40 +01:00
2 changed files with 586 additions and 8 deletions

View File

@@ -229,9 +229,22 @@ 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)
closing_season_ranges: list[tuple[date, date]] = []
# Track date ranges per room/category to detect overlaps
inventory_ranges: dict[tuple[str, str | None], list[tuple[date, date]]] = {}
for inventory in inventories:
sac = inventory.status_application_control
@@ -245,16 +258,17 @@ class FreeRoomsAction(AlpineBitsAction):
# Validate closing seasons
if is_closing:
if inventory.inv_counts is not None:
raise FreeRoomsProcessingError(
"Closing seasons cannot contain InvCounts data",
HttpStatusCode.BAD_REQUEST,
)
# Closing seasons are only allowed in CompleteSet - fail fast
if update_type != "CompleteSet":
raise FreeRoomsProcessingError(
"Closing seasons are only allowed on CompleteSet updates",
HttpStatusCode.BAD_REQUEST,
)
if inventory.inv_counts is not None:
raise FreeRoomsProcessingError(
"Closing seasons cannot contain InvCounts data",
HttpStatusCode.BAD_REQUEST,
)
if enforce_closing_order and encountered_standard:
raise FreeRoomsProcessingError(
"Closing seasons must appear before other inventory entries",
@@ -265,8 +279,9 @@ class FreeRoomsAction(AlpineBitsAction):
"Closing season entries cannot specify InvTypeCode or InvCode",
HttpStatusCode.BAD_REQUEST,
)
# Validate date range
self._parse_date_range(sac.start, sac.end)
# Validate and store date range
start_date, end_date = self._parse_date_range(sac.start, sac.end)
closing_season_ranges.append((start_date, end_date))
continue
# Mark that we've seen a non-closing inventory entry
@@ -281,7 +296,32 @@ class FreeRoomsAction(AlpineBitsAction):
)
# Validate date range
self._parse_date_range(sac.start, sac.end)
start_date, end_date = self._parse_date_range(sac.start, sac.end)
# Check for overlap with closing seasons
for closing_start, closing_end in closing_season_ranges:
if self._date_ranges_overlap(start_date, end_date, closing_start, closing_end):
raise FreeRoomsProcessingError(
f"Inventory entry ({start_date} to {end_date}) overlaps with closing season ({closing_start} to {closing_end})",
HttpStatusCode.BAD_REQUEST,
)
# Check for overlap with other inventory entries for the same room/category
inv_code = sac.inv_code.strip() if sac.inv_code else None
inventory_key = (inv_type_code, inv_code)
if inventory_key in inventory_ranges:
for existing_start, existing_end in inventory_ranges[inventory_key]:
if self._date_ranges_overlap(start_date, end_date, existing_start, existing_end):
room_desc = f"room '{inv_code}'" if inv_code else f"category '{inv_type_code}'"
raise FreeRoomsProcessingError(
f"Overlapping date ranges for {room_desc}: ({start_date} to {end_date}) and ({existing_start} to {existing_end})",
HttpStatusCode.BAD_REQUEST,
)
else:
inventory_ranges[inventory_key] = []
inventory_ranges[inventory_key].append((start_date, end_date))
# Validate that we don't mix categories and individual rooms
has_inv_code = sac.inv_code is not None and sac.inv_code.strip() != ""
@@ -303,6 +343,15 @@ class FreeRoomsAction(AlpineBitsAction):
# Validate counts
self._extract_counts(inventory.inv_counts)
# Check for overlapping closing seasons
for i, (start1, end1) in enumerate(closing_season_ranges):
for start2, end2 in closing_season_ranges[i + 1:]:
if self._date_ranges_overlap(start1, end1, start2, end2):
raise FreeRoomsProcessingError(
f"Closing seasons overlap: ({start1} to {end1}) and ({start2} to {end2})",
HttpStatusCode.BAD_REQUEST,
)
async def _process_complete_set(
self,
session: AsyncSession,
@@ -315,7 +364,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 +395,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,
@@ -497,6 +567,15 @@ class FreeRoomsAction(AlpineBitsAction):
)
return start_date, end_date
def _date_ranges_overlap(
self, start1: date, end1: date, start2: date, end2: date
) -> bool:
"""Check if two date ranges overlap (inclusive).
Returns True if the ranges have any dates in common.
"""
return start1 <= end2 and start2 <= end1
def _iter_days(self, start_date: date, end_date: date):
current = start_date
while current <= end_date:

View File

@@ -560,3 +560,502 @@ 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"
@pytest.mark.asyncio
async def test_closing_season_overlapping_with_inventory_is_rejected(db_session: AsyncSession):
"""Test that closing seasons cannot overlap with regular inventory entries."""
await insert_test_hotel(db_session)
action = make_action()
# Closing season from July 31 to Sept 30, with inventory from Aug 1-10 (overlaps!)
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2022-07-31" End="2022-09-30" AllInvCode="true" />
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-01" End="2022-08-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.BAD_REQUEST
assert "overlaps with closing season" in response.xml_content
@pytest.mark.asyncio
async def test_overlapping_closing_seasons_are_rejected(db_session: AsyncSession):
"""Test that multiple closing seasons cannot overlap with each other."""
await insert_test_hotel(db_session)
action = make_action()
# Two overlapping closing seasons
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2022-07-01" End="2022-07-31" AllInvCode="true" />
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-07-15" End="2022-08-15" AllInvCode="true" />
</Inventory>
"""
)
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 overlap" in response.xml_content
@pytest.mark.asyncio
async def test_non_overlapping_closing_seasons_are_allowed(db_session: AsyncSession):
"""Test that multiple non-overlapping closing seasons are allowed."""
await insert_test_hotel(db_session)
action = make_action()
# Two non-overlapping closing seasons
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2022-07-01" End="2022-07-15" AllInvCode="true" />
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-01" End="2022-08-15" AllInvCode="true" />
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-07-16" End="2022-07-31" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="5" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.OK
# Verify closing seasons were created
rows = (
await db_session.execute(
select(RoomAvailability).where(RoomAvailability.is_closing_season.is_(True))
)
).scalars().all()
# 15 days in July + 15 days in August = 30 closing season days
assert len(rows) == 30
@pytest.mark.asyncio
async def test_adjacent_closing_season_and_inventory_are_allowed(db_session: AsyncSession):
"""Test that closing seasons and inventory can be adjacent (not overlapping) without error."""
await insert_test_hotel(db_session)
action = make_action()
# Closing season ends July 31, inventory starts Aug 1 (adjacent, not overlapping)
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2022-07-01" End="2022-07-31" AllInvCode="true" />
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-01" End="2022-08-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.OK
@pytest.mark.asyncio
async def test_overlapping_inventory_for_same_category_is_rejected(db_session: AsyncSession):
"""Test that overlapping date ranges for the same room category are rejected."""
await insert_test_hotel(db_session)
action = make_action()
# Two overlapping date ranges for DOUBLE category
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2022-08-01" End="2022-08-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-11" End="2022-08-20" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="5" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-15" End="2022-08-30" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.BAD_REQUEST
assert "Overlapping date ranges for category 'DOUBLE'" in response.xml_content
@pytest.mark.asyncio
async def test_overlapping_inventory_for_same_room_is_rejected(db_session: AsyncSession):
"""Test that overlapping date ranges for the same individual room are rejected."""
await insert_test_hotel(db_session)
action = make_action()
# Two overlapping date ranges for room 101
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2022-08-01" End="2022-08-15" InvTypeCode="DOUBLE" InvCode="101" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-10" End="2022-08-20" InvTypeCode="DOUBLE" InvCode="101" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.BAD_REQUEST
assert "Overlapping date ranges for room '101'" in response.xml_content
@pytest.mark.asyncio
async def test_non_overlapping_inventory_for_same_category_is_allowed(db_session: AsyncSession):
"""Test that non-overlapping date ranges for the same category are allowed."""
await insert_test_hotel(db_session)
action = make_action()
# Three non-overlapping date ranges for DOUBLE category
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2022-08-01" End="2022-08-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-11" End="2022-08-20" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="5" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-21" End="2022-08-30" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.OK
# Verify all dates were created
rows = (
await db_session.execute(
select(RoomAvailability).order_by(RoomAvailability.date)
)
).scalars().all()
assert len(rows) == 30 # Aug 1-30
@pytest.mark.asyncio
async def test_overlapping_inventory_for_different_categories_is_allowed(db_session: AsyncSession):
"""Test that overlapping dates for different room categories are allowed."""
await insert_test_hotel(db_session)
action = make_action()
# Overlapping dates but for different categories (DOUBLE vs SINGLE)
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2022-08-01" End="2022-08-15" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-10" End="2022-08-20" InvTypeCode="SINGLE" />
<InvCounts>
<InvCount CountType="2" Count="2" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.OK
@pytest.mark.asyncio
async def test_overlapping_inventory_for_different_rooms_is_allowed(db_session: AsyncSession):
"""Test that overlapping dates for different individual rooms are allowed."""
await insert_test_hotel(db_session)
action = make_action()
# Overlapping dates but for different rooms (101 vs 102)
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2022-08-01" End="2022-08-15" InvTypeCode="DOUBLE" InvCode="101" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2022-08-10" End="2022-08-20" InvTypeCode="DOUBLE" InvCode="102" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
"""
)
response = await action.handle(
"OTA_HotelInvCountNotif:FreeRooms",
xml,
Version.V2024_10,
make_client_info(),
db_session,
)
assert response.status_code == HttpStatusCode.OK