"""Unit tests for FreeRoomsAction.""" from __future__ import annotations from datetime import UTC, datetime import pytest import pytest_asyncio from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from alpine_bits_python.alpinebits_server import AlpineBitsClientInfo, Version from alpine_bits_python.const import HttpStatusCode from alpine_bits_python.db import Base, Hotel, HotelInventory, RoomAvailability from alpine_bits_python.hotel_service import hash_password from alpine_bits_python.free_rooms_action import FreeRoomsAction TEST_CONFIG = { "alpine_bits_auth": [ { "hotel_id": "TESTHOTEL", "hotel_name": "Unit Test Hotel", "username": "testuser", "password": "testpass", } ] } def build_complete_set_xml(body: str, hotel_code: str = "TESTHOTEL") -> str: return f""" {body} """ def build_delta_xml(body: str, hotel_code: str = "TESTHOTEL") -> str: return f""" {body} """ def daily_inventory(start: str, end: str, inv_type: str = "DBL", count: int = 3) -> str: return f""" """ @pytest_asyncio.fixture async def db_engine(): engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield engine await engine.dispose() @pytest_asyncio.fixture async def db_session(db_engine): session_factory = async_sessionmaker(db_engine, expire_on_commit=False, class_=AsyncSession) async with session_factory() as session: yield session async def insert_test_hotel(session: AsyncSession, hotel_id: str = "TESTHOTEL"): hotel = Hotel( hotel_id=hotel_id, hotel_name="Unit Test Hotel", username="testuser", password_hash=hash_password("testpass"), created_at=datetime.now(UTC), updated_at=datetime.now(UTC), is_active=True, ) session.add(hotel) await session.commit() return hotel def make_action() -> FreeRoomsAction: return FreeRoomsAction(config=TEST_CONFIG) def make_client_info() -> AlpineBitsClientInfo: return AlpineBitsClientInfo(username="testuser", password="testpass") @pytest.mark.asyncio async def test_complete_set_creates_inventory_and_availability(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() xml = build_complete_set_xml( daily_inventory("2025-01-01", "2025-01-03", inv_type="DBL", count=4) ) response = await action.handle( action="OTA_HotelInvCountNotif:FreeRooms", request_xml=xml, version=Version.V2024_10, client_info=make_client_info(), dbsession=db_session, ) assert response.status_code == HttpStatusCode.OK inventories = (await db_session.execute(select(HotelInventory))).scalars().all() assert len(inventories) == 1 assert inventories[0].inv_type_code == "DBL" rows = ( await db_session.execute( select(RoomAvailability).order_by(RoomAvailability.date) ) ).scalars().all() assert len(rows) == 3 assert rows[0].bookable_type_2 == 4 assert rows[0].update_type == "CompleteSet" @pytest.mark.asyncio async def test_complete_set_replaces_previous_availability(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() xml_initial = build_complete_set_xml(daily_inventory("2025-02-01", "2025-02-02", count=5)) xml_updated = build_complete_set_xml(daily_inventory("2025-02-01", "2025-02-01", count=1)) await action.handle( "OTA_HotelInvCountNotif:FreeRooms", xml_initial, Version.V2024_10, make_client_info(), db_session, ) await action.handle( "OTA_HotelInvCountNotif:FreeRooms", xml_updated, Version.V2024_10, make_client_info(), db_session, ) rows = ( await db_session.execute(select(RoomAvailability).order_by(RoomAvailability.date)) ).scalars().all() assert len(rows) == 1 assert rows[0].date.isoformat() == "2025-02-01" assert rows[0].bookable_type_2 == 1 @pytest.mark.asyncio async def test_delta_updates_only_specified_dates(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() complete_xml = build_complete_set_xml(daily_inventory("2025-03-01", "2025-03-03", count=2)) delta_xml = build_delta_xml(daily_inventory("2025-03-02", "2025-03-02", count=7)) await action.handle( "OTA_HotelInvCountNotif:FreeRooms", complete_xml, Version.V2024_10, make_client_info(), db_session, ) await action.handle( "OTA_HotelInvCountNotif:FreeRooms", delta_xml, Version.V2024_10, make_client_info(), db_session, ) rows = ( await db_session.execute(select(RoomAvailability).order_by(RoomAvailability.date)) ).scalars().all() counts = {row.date.isoformat(): row.bookable_type_2 for row in rows} assert counts == { "2025-03-01": 2, "2025-03-02": 7, "2025-03-03": 2, } assert all(row.update_type in {"CompleteSet", "Delta"} for row in rows) @pytest.mark.asyncio async def test_closing_season_entries_marked_correctly(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() 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 inventories = (await db_session.execute(select(HotelInventory))).scalars().all() closing_inventory = next(inv for inv in inventories if inv.inv_type_code == "__CLOSE") assert closing_inventory.inv_code is None rows = ( await db_session.execute(select(RoomAvailability).order_by(RoomAvailability.date)) ).scalars().all() closing_rows = [row for row in rows if row.is_closing_season] assert len(closing_rows) == 2 assert all(row.bookable_type_2 is None for row in closing_rows) @pytest.mark.asyncio async def test_closing_season_not_allowed_in_delta(db_session: AsyncSession): 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, ) assert response.status_code == HttpStatusCode.BAD_REQUEST assert "Closing seasons" in response.xml_content @pytest.mark.asyncio async def test_missing_invtypecode_returns_error(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() 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 "InvTypeCode is required" in response.xml_content @pytest.mark.asyncio async def test_duplicate_count_type_rejected(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() 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 "Duplicate CountType" in response.xml_content @pytest.mark.asyncio async def test_invalid_date_range_returns_error(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() 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 "End date cannot be before Start date" in response.xml_content @pytest.mark.asyncio async def test_invalid_credentials_return_unauthorized(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() bad_client = AlpineBitsClientInfo(username="testuser", password="wrongpass") xml = build_complete_set_xml(daily_inventory("2025-09-01", "2025-09-01")) response = await action.handle( "OTA_HotelInvCountNotif:FreeRooms", xml, Version.V2024_10, bad_client, db_session, ) assert response.status_code == HttpStatusCode.UNAUTHORIZED assert "Unauthorized" in response.xml_content @pytest.mark.asyncio async def test_invalid_xml_returns_error(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() client_info = make_client_info() response = await action.handle( "OTA_HotelInvCountNotif:FreeRooms", " """ ) response = await action.handle( "OTA_HotelInvCountNotif:FreeRooms", xml, Version.V2024_10, make_client_info(), db_session, ) assert response.status_code == HttpStatusCode.BAD_REQUEST assert "Mixing room categories and individual rooms" in response.xml_content @pytest.mark.asyncio async def test_mixing_rooms_and_categories_is_rejected(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() # First inventory is an individual room (with InvCode), second is a category (no InvCode) 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 "Mixing room categories and individual rooms" in response.xml_content @pytest.mark.asyncio async def test_multiple_categories_are_allowed(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() # Multiple category reports (all without InvCode) should be allowed 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_multiple_rooms_are_allowed(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() # Multiple individual room reports (all with InvCode) should be allowed 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_closing_season_with_categories_is_allowed(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() # Closing season followed by category reports should be allowed 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_closing_season_with_rooms_is_allowed(db_session: AsyncSession): await insert_test_hotel(db_session) action = make_action() # Closing season followed by individual room reports should be allowed 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_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"