Compare commits
2 Commits
cdb69bc3c7
...
9bbcb4b971
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bbcb4b971 | ||
|
|
9ac5f38b55 |
@@ -229,9 +229,22 @@ class FreeRoomsAction(AlpineBitsAction):
|
|||||||
HttpStatusCode.BAD_REQUEST,
|
HttpStatusCode.BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Special case: CompleteSet with single empty Inventory element to reset all availability
|
||||||
|
if (
|
||||||
|
update_type == "CompleteSet"
|
||||||
|
and len(inventories) == 1
|
||||||
|
and inventories[0].status_application_control is None
|
||||||
|
and inventories[0].inv_counts is None
|
||||||
|
):
|
||||||
|
# This is valid - it's a reset request
|
||||||
|
return
|
||||||
|
|
||||||
encountered_standard = False
|
encountered_standard = False
|
||||||
has_categories = False # Tracks if we've seen category reports (no InvCode)
|
has_categories = False # Tracks if we've seen category reports (no InvCode)
|
||||||
has_rooms = False # Tracks if we've seen individual room reports (with InvCode)
|
has_rooms = False # Tracks if we've seen individual room reports (with InvCode)
|
||||||
|
closing_season_ranges: list[tuple[date, date]] = []
|
||||||
|
# Track date ranges per room/category to detect overlaps
|
||||||
|
inventory_ranges: dict[tuple[str, str | None], list[tuple[date, date]]] = {}
|
||||||
|
|
||||||
for inventory in inventories:
|
for inventory in inventories:
|
||||||
sac = inventory.status_application_control
|
sac = inventory.status_application_control
|
||||||
@@ -245,16 +258,17 @@ class FreeRoomsAction(AlpineBitsAction):
|
|||||||
|
|
||||||
# Validate closing seasons
|
# Validate closing seasons
|
||||||
if is_closing:
|
if is_closing:
|
||||||
if inventory.inv_counts is not None:
|
# Closing seasons are only allowed in CompleteSet - fail fast
|
||||||
raise FreeRoomsProcessingError(
|
|
||||||
"Closing seasons cannot contain InvCounts data",
|
|
||||||
HttpStatusCode.BAD_REQUEST,
|
|
||||||
)
|
|
||||||
if update_type != "CompleteSet":
|
if update_type != "CompleteSet":
|
||||||
raise FreeRoomsProcessingError(
|
raise FreeRoomsProcessingError(
|
||||||
"Closing seasons are only allowed on CompleteSet updates",
|
"Closing seasons are only allowed on CompleteSet updates",
|
||||||
HttpStatusCode.BAD_REQUEST,
|
HttpStatusCode.BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
if inventory.inv_counts is not None:
|
||||||
|
raise FreeRoomsProcessingError(
|
||||||
|
"Closing seasons cannot contain InvCounts data",
|
||||||
|
HttpStatusCode.BAD_REQUEST,
|
||||||
|
)
|
||||||
if enforce_closing_order and encountered_standard:
|
if enforce_closing_order and encountered_standard:
|
||||||
raise FreeRoomsProcessingError(
|
raise FreeRoomsProcessingError(
|
||||||
"Closing seasons must appear before other inventory entries",
|
"Closing seasons must appear before other inventory entries",
|
||||||
@@ -265,8 +279,9 @@ class FreeRoomsAction(AlpineBitsAction):
|
|||||||
"Closing season entries cannot specify InvTypeCode or InvCode",
|
"Closing season entries cannot specify InvTypeCode or InvCode",
|
||||||
HttpStatusCode.BAD_REQUEST,
|
HttpStatusCode.BAD_REQUEST,
|
||||||
)
|
)
|
||||||
# Validate date range
|
# Validate and store date range
|
||||||
self._parse_date_range(sac.start, sac.end)
|
start_date, end_date = self._parse_date_range(sac.start, sac.end)
|
||||||
|
closing_season_ranges.append((start_date, end_date))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Mark that we've seen a non-closing inventory entry
|
# Mark that we've seen a non-closing inventory entry
|
||||||
@@ -281,7 +296,32 @@ class FreeRoomsAction(AlpineBitsAction):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate date range
|
# Validate date range
|
||||||
self._parse_date_range(sac.start, sac.end)
|
start_date, end_date = self._parse_date_range(sac.start, sac.end)
|
||||||
|
|
||||||
|
# Check for overlap with closing seasons
|
||||||
|
for closing_start, closing_end in closing_season_ranges:
|
||||||
|
if self._date_ranges_overlap(start_date, end_date, closing_start, closing_end):
|
||||||
|
raise FreeRoomsProcessingError(
|
||||||
|
f"Inventory entry ({start_date} to {end_date}) overlaps with closing season ({closing_start} to {closing_end})",
|
||||||
|
HttpStatusCode.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for overlap with other inventory entries for the same room/category
|
||||||
|
inv_code = sac.inv_code.strip() if sac.inv_code else None
|
||||||
|
inventory_key = (inv_type_code, inv_code)
|
||||||
|
|
||||||
|
if inventory_key in inventory_ranges:
|
||||||
|
for existing_start, existing_end in inventory_ranges[inventory_key]:
|
||||||
|
if self._date_ranges_overlap(start_date, end_date, existing_start, existing_end):
|
||||||
|
room_desc = f"room '{inv_code}'" if inv_code else f"category '{inv_type_code}'"
|
||||||
|
raise FreeRoomsProcessingError(
|
||||||
|
f"Overlapping date ranges for {room_desc}: ({start_date} to {end_date}) and ({existing_start} to {existing_end})",
|
||||||
|
HttpStatusCode.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
inventory_ranges[inventory_key] = []
|
||||||
|
|
||||||
|
inventory_ranges[inventory_key].append((start_date, end_date))
|
||||||
|
|
||||||
# Validate that we don't mix categories and individual rooms
|
# Validate that we don't mix categories and individual rooms
|
||||||
has_inv_code = sac.inv_code is not None and sac.inv_code.strip() != ""
|
has_inv_code = sac.inv_code is not None and sac.inv_code.strip() != ""
|
||||||
@@ -303,6 +343,15 @@ class FreeRoomsAction(AlpineBitsAction):
|
|||||||
# Validate counts
|
# Validate counts
|
||||||
self._extract_counts(inventory.inv_counts)
|
self._extract_counts(inventory.inv_counts)
|
||||||
|
|
||||||
|
# Check for overlapping closing seasons
|
||||||
|
for i, (start1, end1) in enumerate(closing_season_ranges):
|
||||||
|
for start2, end2 in closing_season_ranges[i + 1:]:
|
||||||
|
if self._date_ranges_overlap(start1, end1, start2, end2):
|
||||||
|
raise FreeRoomsProcessingError(
|
||||||
|
f"Closing seasons overlap: ({start1} to {end1}) and ({start2} to {end2})",
|
||||||
|
HttpStatusCode.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
async def _process_complete_set(
|
async def _process_complete_set(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -315,7 +364,10 @@ class FreeRoomsAction(AlpineBitsAction):
|
|||||||
self._validate_request(request, update_type, enforce_closing_order=True)
|
self._validate_request(request, update_type, enforce_closing_order=True)
|
||||||
|
|
||||||
# Only delete if validation passes
|
# Only delete if validation passes
|
||||||
|
# Delete availability data for all FreeRooms-sourced inventory
|
||||||
await self._delete_existing_availability(session, hotel.hotel_id)
|
await self._delete_existing_availability(session, hotel.hotel_id)
|
||||||
|
# Delete stale inventory items that are sourced from FreeRooms
|
||||||
|
await self._delete_existing_inventory(session, hotel.hotel_id)
|
||||||
|
|
||||||
# Process the validated request
|
# Process the validated request
|
||||||
await self._process_inventories(
|
await self._process_inventories(
|
||||||
@@ -343,11 +395,29 @@ class FreeRoomsAction(AlpineBitsAction):
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
hotel_id: str,
|
hotel_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Delete all room availability data for a hotel (regardless of source)."""
|
||||||
subquery = select(HotelInventory.id).where(HotelInventory.hotel_id == hotel_id)
|
subquery = select(HotelInventory.id).where(HotelInventory.hotel_id == hotel_id)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(RoomAvailability).where(RoomAvailability.inventory_id.in_(subquery))
|
delete(RoomAvailability).where(RoomAvailability.inventory_id.in_(subquery))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _delete_existing_inventory(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
hotel_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Delete inventory items sourced from FreeRooms.
|
||||||
|
|
||||||
|
This preserves inventory items from other sources (e.g., HotelInventory endpoint)
|
||||||
|
as they are not managed by FreeRooms and should persist across CompleteSet updates.
|
||||||
|
"""
|
||||||
|
await session.execute(
|
||||||
|
delete(HotelInventory).where(
|
||||||
|
HotelInventory.hotel_id == hotel_id,
|
||||||
|
HotelInventory.source == SOURCE_FREEROOMS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _process_inventories(
|
async def _process_inventories(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -497,6 +567,15 @@ class FreeRoomsAction(AlpineBitsAction):
|
|||||||
)
|
)
|
||||||
return start_date, end_date
|
return start_date, end_date
|
||||||
|
|
||||||
|
def _date_ranges_overlap(
|
||||||
|
self, start1: date, end1: date, start2: date, end2: date
|
||||||
|
) -> bool:
|
||||||
|
"""Check if two date ranges overlap (inclusive).
|
||||||
|
|
||||||
|
Returns True if the ranges have any dates in common.
|
||||||
|
"""
|
||||||
|
return start1 <= end2 and start2 <= end1
|
||||||
|
|
||||||
def _iter_days(self, start_date: date, end_date: date):
|
def _iter_days(self, start_date: date, end_date: date):
|
||||||
current = start_date
|
current = start_date
|
||||||
while current <= end_date:
|
while current <= end_date:
|
||||||
|
|||||||
@@ -560,3 +560,502 @@ async def test_closing_season_with_rooms_is_allowed(db_session: AsyncSession):
|
|||||||
db_session,
|
db_session,
|
||||||
)
|
)
|
||||||
assert response.status_code == HttpStatusCode.OK
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user