Files
alpinebits_python/tests/test_free_rooms_action.py
2025-12-04 16:33:11 +01:00

1062 lines
33 KiB
Python

"""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"""<?xml version="1.0" encoding="UTF-8"?>
<OTA_HotelInvCountNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<UniqueID Type="16" ID="1" Instance="CompleteSet"/>
<Inventories HotelCode="{hotel_code}" HotelName="Unit Hotel">
{body}
</Inventories>
</OTA_HotelInvCountNotifRQ>"""
def build_delta_xml(body: str, hotel_code: str = "TESTHOTEL") -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<OTA_HotelInvCountNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<Inventories HotelCode="{hotel_code}" HotelName="Unit Hotel">
{body}
</Inventories>
</OTA_HotelInvCountNotifRQ>"""
def daily_inventory(start: str, end: str, inv_type: str = "DBL", count: int = 3) -> str:
return f"""
<Inventory>
<StatusApplicationControl Start="{start}" End="{end}" InvTypeCode="{inv_type}"/>
<InvCounts>
<InvCount CountType="2" Count="{count}"/>
</InvCounts>
</Inventory>
"""
@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(
"""
<Inventory>
<StatusApplicationControl Start="2025-04-01" End="2025-04-02" AllInvCode="true"/>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-04-03" End="2025-04-03" InvTypeCode="SGL"/>
</Inventory>
"""
)
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(
"""
<Inventory>
<StatusApplicationControl Start="2025-05-01" End="2025-05-02" 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" 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(
"""
<Inventory>
<StatusApplicationControl Start="2025-06-01" End="2025-06-02"/>
</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 "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(
"""
<Inventory>
<StatusApplicationControl Start="2025-07-01" End="2025-07-01" InvTypeCode="SGL"/>
<InvCounts>
<InvCount CountType="2" Count="3"/>
<InvCount CountType="2" Count="4"/>
</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 "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(
"""
<Inventory>
<StatusApplicationControl Start="2025-08-10" End="2025-08-01" InvTypeCode="DBL"/>
</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 "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",
"<invalid",
Version.V2024_10,
client_info,
db_session,
)
assert response.status_code == HttpStatusCode.BAD_REQUEST
assert "Invalid XML payload" in response.xml_content
@pytest.mark.asyncio
async def test_mixing_categories_and_rooms_is_rejected(db_session: AsyncSession):
await insert_test_hotel(db_session)
action = make_action()
# First inventory is a category (no InvCode), second is an individual room (with InvCode)
xml = build_complete_set_xml(
"""
<Inventory>
<StatusApplicationControl Start="2025-08-01" End="2025-08-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-08-21" End="2025-08-30" InvTypeCode="DOUBLE" InvCode="108" />
<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 "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(
"""
<Inventory>
<StatusApplicationControl Start="2025-08-21" End="2025-08-30" InvTypeCode="DOUBLE" InvCode="108" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-08-01" End="2025-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 "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(
"""
<Inventory>
<StatusApplicationControl Start="2025-08-01" End="2025-08-10" InvTypeCode="DOUBLE" />
<InvCounts>
<InvCount CountType="2" Count="3" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-08-11" End="2025-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_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(
"""
<Inventory>
<StatusApplicationControl Start="2025-08-01" End="2025-08-10" InvTypeCode="DOUBLE" InvCode="101" />
<InvCounts>
<InvCount CountType="2" Count="1" />
</InvCounts>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-08-11" End="2025-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
@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(
"""
<Inventory>
<StatusApplicationControl Start="2025-04-01" End="2025-04-02" AllInvCode="true"/>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-04-03" End="2025-04-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_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(
"""
<Inventory>
<StatusApplicationControl Start="2025-04-01" End="2025-04-02" AllInvCode="true"/>
</Inventory>
<Inventory>
<StatusApplicationControl Start="2025-04-03" End="2025-04-10" 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.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