Free rooms first implementation
This commit is contained in:
215
tests/test_api_freerooms.py
Normal file
215
tests/test_api_freerooms.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Integration tests for the FreeRooms endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import urllib.parse
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from alpine_bits_python.alpinebits_server import AlpineBitsServer
|
||||
from alpine_bits_python.api import app
|
||||
from alpine_bits_python.const import HttpStatusCode
|
||||
from alpine_bits_python.db import Base, Hotel, RoomAvailability
|
||||
|
||||
|
||||
def build_request_xml(body: str, include_unique_id: bool = True) -> str:
|
||||
unique = (
|
||||
'<UniqueID Type="16" ID="1" Instance="CompleteSet"/>'
|
||||
if include_unique_id
|
||||
else ""
|
||||
)
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelInvCountNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
{unique}
|
||||
<Inventories HotelCode="HOTEL123" HotelName="Integration Hotel">
|
||||
{body}
|
||||
</Inventories>
|
||||
</OTA_HotelInvCountNotifRQ>"""
|
||||
|
||||
|
||||
INVENTORY_A = """
|
||||
<Inventory>
|
||||
<StatusApplicationControl Start="2025-10-01" End="2025-10-03" InvTypeCode="DBL"/>
|
||||
<InvCounts>
|
||||
<InvCount CountType="2" Count="3"/>
|
||||
</InvCounts>
|
||||
</Inventory>
|
||||
"""
|
||||
|
||||
INVENTORY_B = """
|
||||
<Inventory>
|
||||
<StatusApplicationControl Start="2025-10-02" End="2025-10-02" InvTypeCode="DBL"/>
|
||||
<InvCounts>
|
||||
<InvCount CountType="2" Count="1"/>
|
||||
</InvCounts>
|
||||
</Inventory>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def freerooms_test_config():
|
||||
return {
|
||||
"server": {
|
||||
"codecontext": "ADVERTISING",
|
||||
"code": "70597314",
|
||||
"companyname": "99tales Gmbh",
|
||||
"res_id_source_context": "99tales",
|
||||
},
|
||||
"alpine_bits_auth": [
|
||||
{
|
||||
"hotel_id": "HOTEL123",
|
||||
"hotel_name": "Integration Hotel",
|
||||
"username": "testuser",
|
||||
"password": "testpass",
|
||||
}
|
||||
],
|
||||
"database": {"url": "sqlite+aiosqlite:///:memory:"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def freerooms_client(freerooms_test_config):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
|
||||
async def create_tables():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
asyncio.run(create_tables())
|
||||
|
||||
with patch("alpine_bits_python.api.load_config", return_value=freerooms_test_config), patch(
|
||||
"alpine_bits_python.api.create_database_engine", return_value=engine
|
||||
):
|
||||
app.state.engine = engine
|
||||
app.state.async_sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
app.state.config = freerooms_test_config
|
||||
app.state.alpine_bits_server = AlpineBitsServer(freerooms_test_config)
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def freerooms_headers():
|
||||
return {
|
||||
"Authorization": "Basic dGVzdHVzZXI6dGVzdHBhc3M=",
|
||||
"X-AlpineBits-ClientProtocolVersion": "2024-10",
|
||||
}
|
||||
|
||||
|
||||
def seed_hotel_if_missing(client: TestClient):
|
||||
async def _seed():
|
||||
async_sessionmaker = client.app.state.async_sessionmaker
|
||||
async with async_sessionmaker() as session:
|
||||
result = await session.execute(
|
||||
select(Hotel).where(Hotel.hotel_id == "HOTEL123")
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
return
|
||||
session.add(
|
||||
Hotel(
|
||||
hotel_id="HOTEL123",
|
||||
hotel_name="Integration Hotel",
|
||||
username="testuser",
|
||||
password_hash="integration-hash",
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
asyncio.run(_seed())
|
||||
|
||||
|
||||
def fetch_availability(client: TestClient):
|
||||
async def _fetch():
|
||||
async_sessionmaker = client.app.state.async_sessionmaker
|
||||
async with async_sessionmaker() as session:
|
||||
result = await session.execute(
|
||||
select(RoomAvailability).order_by(RoomAvailability.date)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
return asyncio.run(_fetch())
|
||||
|
||||
|
||||
def test_freerooms_endpoint_complete_set(freerooms_client: TestClient, freerooms_headers):
|
||||
seed_hotel_if_missing(freerooms_client)
|
||||
xml = build_request_xml(INVENTORY_A, include_unique_id=True)
|
||||
|
||||
response = freerooms_client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
data={"action": "OTA_HotelInvCountNotif:FreeRooms", "request": xml},
|
||||
headers=freerooms_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == HttpStatusCode.OK
|
||||
assert "<Success" in response.text
|
||||
|
||||
rows = fetch_availability(freerooms_client)
|
||||
assert len(rows) == 3
|
||||
assert rows[0].count_type_2 == 3
|
||||
|
||||
|
||||
def test_freerooms_endpoint_delta_updates_existing_rows(
|
||||
freerooms_client: TestClient, freerooms_headers
|
||||
):
|
||||
seed_hotel_if_missing(freerooms_client)
|
||||
complete_xml = build_request_xml(INVENTORY_A, include_unique_id=True)
|
||||
delta_xml = build_request_xml(INVENTORY_B, include_unique_id=False)
|
||||
|
||||
response = freerooms_client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
data={"action": "OTA_HotelInvCountNotif:FreeRooms", "request": complete_xml},
|
||||
headers=freerooms_headers,
|
||||
)
|
||||
assert response.status_code == HttpStatusCode.OK
|
||||
|
||||
response = freerooms_client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
data={"action": "OTA_HotelInvCountNotif:FreeRooms", "request": delta_xml},
|
||||
headers=freerooms_headers,
|
||||
)
|
||||
assert response.status_code == HttpStatusCode.OK
|
||||
|
||||
rows = fetch_availability(freerooms_client)
|
||||
counts = {row.date.isoformat(): row.count_type_2 for row in rows}
|
||||
assert counts["2025-10-02"] == 1
|
||||
assert counts["2025-10-01"] == 3
|
||||
|
||||
|
||||
def test_freerooms_endpoint_accepts_gzip_payload(
|
||||
freerooms_client: TestClient, freerooms_headers
|
||||
):
|
||||
seed_hotel_if_missing(freerooms_client)
|
||||
xml = build_request_xml(INVENTORY_A, include_unique_id=True)
|
||||
encoded = urllib.parse.urlencode(
|
||||
{"action": "OTA_HotelInvCountNotif:FreeRooms", "request": xml}
|
||||
).encode("utf-8")
|
||||
compressed = gzip.compress(encoded)
|
||||
|
||||
headers = {
|
||||
**freerooms_headers,
|
||||
"Content-Encoding": "gzip",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
response = freerooms_client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
data=compressed,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == HttpStatusCode.OK
|
||||
assert "<Success" in response.text
|
||||
|
||||
rows = fetch_availability(freerooms_client)
|
||||
assert len(rows) == 3
|
||||
367
tests/test_free_rooms_action.py
Normal file
367
tests/test_free_rooms_action.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""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.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="bcrypt-hash",
|
||||
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].count_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].count_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.count_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.count_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
|
||||
Reference in New Issue
Block a user