From a6837197b62c7e194f80d13d40878dd51b78a427 Mon Sep 17 00:00:00 2001 From: Jonas Linter <{email_address}> Date: Thu, 4 Dec 2025 16:33:11 +0100 Subject: [PATCH] FIxed date range overlap --- src/alpine_bits_python/free_rooms_action.py | 64 +++- tests/test_free_rooms_action.py | 336 ++++++++++++++++++++ 2 files changed, 392 insertions(+), 8 deletions(-) diff --git a/src/alpine_bits_python/free_rooms_action.py b/src/alpine_bits_python/free_rooms_action.py index 472671f..ca7e5a2 100644 --- a/src/alpine_bits_python/free_rooms_action.py +++ b/src/alpine_bits_python/free_rooms_action.py @@ -242,6 +242,9 @@ class FreeRoomsAction(AlpineBitsAction): 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 @@ -255,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", @@ -275,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 @@ -291,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() != "" @@ -313,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, @@ -528,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: diff --git a/tests/test_free_rooms_action.py b/tests/test_free_rooms_action.py index 0a4d943..a1215b1 100644 --- a/tests/test_free_rooms_action.py +++ b/tests/test_free_rooms_action.py @@ -723,3 +723,339 @@ async def test_complete_set_preserves_inventory_from_other_sources(db_session: A # 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( + """ + + + + + + + + + + """ + ) + + 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( + """ + + + + + + + """ + ) + + 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( + """ + + + + + + + + + + + + + """ + ) + + 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( + """ + + + + + + + + + + """ + ) + + 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( + """ + + + + + + + + + + + + + + + + + + + """ + ) + + 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( + """ + + + + + + + + + + + + + """ + ) + + 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( + """ + + + + + + + + + + + + + + + + + + + """ + ) + + 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( + """ + + + + + + + + + + + + + """ + ) + + 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( + """ + + + + + + + + + + + + + """ + ) + + response = await action.handle( + "OTA_HotelInvCountNotif:FreeRooms", + xml, + Version.V2024_10, + make_client_info(), + db_session, + ) + + assert response.status_code == HttpStatusCode.OK