1093 lines
34 KiB
Python
1093 lines
34 KiB
Python
"""Unit tests for FreeRoomsAction."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
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_seasons_test_file(db_session: AsyncSession):
|
|
await insert_test_hotel(db_session)
|
|
action = make_action()
|
|
|
|
Path(__file__).parent / "test_data" / "ClosingSeasons.xml"
|
|
|
|
xml = (Path(__file__).parent / "test_data" / "ClosingSeasons.xml").read_text()
|
|
|
|
response = await action.handle(
|
|
"OTA_HotelInvCountNotif:FreeRooms",
|
|
xml,
|
|
Version.V2024_10,
|
|
make_client_info(),
|
|
db_session,
|
|
)
|
|
assert response.status_code == HttpStatusCode.OK, f"Response was not OK {response.xml_content}"
|
|
|
|
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]
|
|
# Closing season from 2025-12-20 to 2025-12-23 = 4 days
|
|
assert len(closing_rows) == 4
|
|
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
|