From 593fd0fc288883b09106133a2e542371a773755c Mon Sep 17 00:00:00 2001 From: Jonas Linter <{email_address}> Date: Mon, 1 Dec 2025 11:12:22 +0100 Subject: [PATCH] Fixed room upsert logic --- src/alpine_bits_python/conversion_service.py | 35 +- tests/helpers/README.md | 197 ++++++++++ tests/helpers/__init__.py | 13 + tests/helpers/xml_builders.py | 392 +++++++++++++++++++ tests/test_conversion_service.py | 341 +++++++++++++++- tests/test_xml_builders.py | 327 ++++++++++++++++ 6 files changed, 1293 insertions(+), 12 deletions(-) create mode 100644 tests/helpers/README.md create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/xml_builders.py create mode 100644 tests/test_xml_builders.py diff --git a/src/alpine_bits_python/conversion_service.py b/src/alpine_bits_python/conversion_service.py index f0cec19..679d3e7 100644 --- a/src/alpine_bits_python/conversion_service.py +++ b/src/alpine_bits_python/conversion_service.py @@ -731,17 +731,10 @@ class ConversionService: # Flush to ensure conversion has an ID before creating room reservations await session.flush() - # Batch-load existing room reservations to avoid N+1 queries - room_numbers = [ - rm.get("roomNumber") for rm in room_reservations.findall("roomReservation") - ] - pms_hotel_reservation_ids = [ - f"{pms_reservation_id}_{room_num}" for room_num in room_numbers - ] - + # Fetch ALL existing rooms for this conversion (not just the ones in current XML) existing_rooms_result = await session.execute( select(ConversionRoom).where( - ConversionRoom.pms_hotel_reservation_id.in_(pms_hotel_reservation_ids) + ConversionRoom.conversion_id == conversion.id ) ) existing_rooms = { @@ -749,6 +742,9 @@ class ConversionService: for room in existing_rooms_result.scalars().all() } + # Track which room IDs are present in the current XML + current_pms_hotel_reservation_ids = set() + # Process room reservations for room_reservation in room_reservations.findall("roomReservation"): # Extract room reservation details @@ -786,6 +782,9 @@ class ConversionService: # This allows updating the same room reservation if it appears again pms_hotel_reservation_id = f"{pms_reservation_id}_{room_number}" + # Track this room as present in current XML + current_pms_hotel_reservation_ids.add(pms_hotel_reservation_id) + # Process daily sales and extract total revenue daily_sales_elem = room_reservation.find("dailySales") daily_sales_list = [] @@ -880,6 +879,24 @@ class ConversionService: num_adults, ) + # Delete room entries that are no longer present in the current XML + # This handles cases where a reservation is updated and room numbers change + rooms_to_delete = [ + room + for pms_id, room in existing_rooms.items() + if pms_id not in current_pms_hotel_reservation_ids + ] + + if rooms_to_delete: + for room in rooms_to_delete: + await session.delete(room) + _LOGGER.debug( + "Deleted room reservation %s (pms_id=%s, room=%s) - no longer in current XML", + room.id, + room.pms_hotel_reservation_id, + room.room_number, + ) + return stats diff --git a/tests/helpers/README.md b/tests/helpers/README.md new file mode 100644 index 0000000..5943dd2 --- /dev/null +++ b/tests/helpers/README.md @@ -0,0 +1,197 @@ +# Test Helpers + +This directory contains helper utilities for creating test data. + +## XML Builders + +The `xml_builders` module provides convenient builder classes for creating reservation XML structures used in conversion service tests. + +### Quick Start + +```python +from tests.helpers import ReservationXMLBuilder + +# Create a simple reservation +xml = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12345", + reservation_number="RES-001", + reservation_date="2025-11-14", + ) + .set_guest( + guest_id="guest_001", + first_name="John", + last_name="Doe", + email="john@example.com", + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-05", + revenue_logis_per_day=150.0, # Fixed revenue per night + ) + .build_xml() +) +``` + +### Features + +#### ReservationXMLBuilder + +The main builder class for creating reservation XML structures. + +**Key Features:** +- Fluent API for method chaining +- Automatic daily sales generation from arrival to departure +- Convenient revenue-per-day specification (no need to manually create each dailySale) +- Support for advertising campaign data +- Guest information with optional fields + +**Example - Multi-room reservation:** + +```python +xml = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12345", + reservation_number="RES-001", + reservation_date="2025-11-14", + ) + .set_guest( + guest_id="guest_001", + first_name="Jane", + last_name="Smith", + email="jane@example.com", + country_code="US", + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-05", + room_number="101", + room_type="DZV", + revenue_logis_per_day=150.0, + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-05", + room_number="102", + room_type="DZM", + revenue_logis_per_day=200.0, + ) + .build_xml() +) +``` + +#### Daily Sales Generation + +The builder automatically generates `` entries for each day from arrival to departure (inclusive). + +- **Days before departure**: Include `revenueTotal` and `revenueLogis` attributes +- **Departure day**: No revenue attributes (just the date) + +**Example:** +```python +# A 3-night stay (Dec 1-4) +.add_room( + arrival="2025-12-01", + departure="2025-12-04", + revenue_logis_per_day=160.0, +) +``` + +Generates: +```xml + + + + + + +``` + +#### MultiReservationXMLBuilder + +For creating XML documents with multiple reservations: + +```python +from tests.helpers import ReservationXMLBuilder, MultiReservationXMLBuilder + +multi_builder = MultiReservationXMLBuilder() + +# Add first reservation +res1 = ( + ReservationXMLBuilder(...) + .set_guest(...) + .add_room(...) +) +multi_builder.add_reservation(res1) + +# Add second reservation +res2 = ( + ReservationXMLBuilder(...) + .set_guest(...) + .add_room(...) +) +multi_builder.add_reservation(res2) + +xml = multi_builder.build_xml() +``` + +#### RoomReservationBuilder + +Low-level builder for creating individual room reservations. Usually you'll use `ReservationXMLBuilder.add_room()` instead, but this is available for advanced use cases. + +```python +from tests.helpers import RoomReservationBuilder + +room_builder = RoomReservationBuilder( + arrival="2025-12-01", + departure="2025-12-05", + room_type="DZV", + room_number="101", + revenue_logis_per_day=150.0, +) + +# Get the XML element (not a string) +room_elem = room_builder.build() +``` + +### Common Parameters + +**ReservationXMLBuilder:** +- `hotel_id` - Hotel ID (required) +- `reservation_id` - Reservation ID (required) +- `reservation_number` - Reservation number (required) +- `reservation_date` - Reservation date YYYY-MM-DD (required) +- `creation_time` - Creation timestamp (optional, defaults to reservation_date + T00:00:00) +- `advertising_medium` - Advertising medium (optional) +- `advertising_partner` - Advertising partner (optional) +- `advertising_campagne` - Advertising campaign (optional) + +**set_guest() parameters:** +- `guest_id` - Guest ID (required) +- `first_name` - First name (required) +- `last_name` - Last name (required) +- `email` - Email address (required) +- `language` - Language code (default: "en") +- `gender` - Gender (optional) +- `country_code` - Country code (optional) +- `country` - Country name (optional) + +**add_room() parameters:** +- `arrival` - Arrival date YYYY-MM-DD (required) +- `departure` - Departure date YYYY-MM-DD (required) +- `room_type` - Room type code (default: "DZV") +- `room_number` - Room number (default: "101") +- `status` - Reservation status (default: "reserved") +- `adults` - Number of adults (default: 2) +- `children` - Number of children (default: 0) +- `infants` - Number of infants (default: 0) +- `rate_plan_code` - Rate plan code (default: "STANDARD") +- `revenue_logis_per_day` - Fixed revenue per night (optional, generates daily sales) +- `revenue_total_per_day` - Total revenue per night (optional, defaults to revenue_logis_per_day) + +### See Also + +- [tests/test_xml_builders.py](../test_xml_builders.py) - Unit tests demonstrating all features +- [tests/test_conversion_service.py](../test_conversion_service.py) - Integration examples (TestXMLBuilderUsage class) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..0b8d7fd --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,13 @@ +"""Test helper utilities for creating test data.""" + +from .xml_builders import ( + ReservationXMLBuilder, + MultiReservationXMLBuilder, + RoomReservationBuilder, +) + +__all__ = [ + "ReservationXMLBuilder", + "MultiReservationXMLBuilder", + "RoomReservationBuilder", +] diff --git a/tests/helpers/xml_builders.py b/tests/helpers/xml_builders.py new file mode 100644 index 0000000..0bf622c --- /dev/null +++ b/tests/helpers/xml_builders.py @@ -0,0 +1,392 @@ +"""XML builder helpers for creating test reservation data. + +This module provides convenient builder classes for generating reservation XML +structures used in conversion service tests. +""" + +from datetime import datetime, timedelta +from typing import Optional +from xml.etree import ElementTree as ET + + +class RoomReservationBuilder: + """Builder for creating roomReservation XML elements with daily sales.""" + + def __init__( + self, + arrival: str, + departure: str, + room_type: str = "DZV", + room_number: str = "101", + status: str = "reserved", + adults: int = 2, + children: int = 0, + infants: int = 0, + rate_plan_code: str = "STANDARD", + connected_room_type: str = "0", + revenue_logis_per_day: Optional[float] = None, + revenue_total_per_day: Optional[float] = None, + ): + """Initialize room reservation builder. + + Args: + arrival: Arrival date in YYYY-MM-DD format + departure: Departure date in YYYY-MM-DD format + room_type: Room type code + room_number: Room number + status: Reservation status (reserved, request, confirmed, etc.) + adults: Number of adults + children: Number of children + infants: Number of infants + rate_plan_code: Rate plan code + connected_room_type: Connected room type code + revenue_logis_per_day: Revenue per day (if None, no revenue attributes) + revenue_total_per_day: Total revenue per day (defaults to revenue_logis_per_day) + """ + self.arrival = arrival + self.departure = departure + self.room_type = room_type + self.room_number = room_number + self.status = status + self.adults = adults + self.children = children + self.infants = infants + self.rate_plan_code = rate_plan_code + self.connected_room_type = connected_room_type + self.revenue_logis_per_day = revenue_logis_per_day + self.revenue_total_per_day = revenue_total_per_day or revenue_logis_per_day + + def build(self) -> ET.Element: + """Build the roomReservation XML element with daily sales. + + Returns: + XML Element for the room reservation + """ + room_attrs = { + "arrival": self.arrival, + "departure": self.departure, + "status": self.status, + "roomType": self.room_type, + "roomNumber": self.room_number, + "adults": str(self.adults), + "ratePlanCode": self.rate_plan_code, + "connectedRoomType": self.connected_room_type, + } + + if self.children > 0: + room_attrs["children"] = str(self.children) + if self.infants > 0: + room_attrs["infants"] = str(self.infants) + + room_elem = ET.Element("roomReservation", room_attrs) + + # Create dailySales element + daily_sales_elem = ET.SubElement(room_elem, "dailySales") + + # Generate daily sale entries from arrival to departure (inclusive of departure for the no-revenue entry) + arrival_date = datetime.strptime(self.arrival, "%Y-%m-%d") + departure_date = datetime.strptime(self.departure, "%Y-%m-%d") + + current_date = arrival_date + while current_date <= departure_date: + date_str = current_date.strftime("%Y-%m-%d") + daily_sale_attrs = {"date": date_str} + + # Add revenue attributes for all days except departure day + if current_date < departure_date and self.revenue_logis_per_day is not None: + daily_sale_attrs["revenueTotal"] = str(self.revenue_total_per_day) + daily_sale_attrs["revenueLogis"] = str(self.revenue_logis_per_day) + + ET.SubElement(daily_sales_elem, "dailySale", daily_sale_attrs) + current_date += timedelta(days=1) + + return room_elem + + +class ReservationXMLBuilder: + """Builder for creating complete reservation XML structures for testing. + + This builder provides a fluent interface for constructing reservation XML + that matches the format expected by the ConversionService. + + Example usage: + builder = ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12345", + reservation_number="RES-001", + reservation_date="2025-11-14" + ) + builder.set_guest( + guest_id="guest_001", + first_name="John", + last_name="Doe", + email="john@example.com" + ) + builder.add_room( + arrival="2025-12-01", + departure="2025-12-05", + revenue_logis_per_day=150.0 + ) + xml_string = builder.build_xml() + """ + + def __init__( + self, + hotel_id: str, + reservation_id: str, + reservation_number: str, + reservation_date: str, + creation_time: Optional[str] = None, + reservation_type: str = "reservation", + advertising_medium: Optional[str] = None, + advertising_partner: Optional[str] = None, + advertising_campagne: Optional[str] = None, + ): + """Initialize reservation builder. + + Args: + hotel_id: Hotel ID + reservation_id: Reservation ID + reservation_number: Reservation number + reservation_date: Reservation date in YYYY-MM-DD format + creation_time: Creation timestamp (defaults to reservation_date + T00:00:00) + reservation_type: Type of reservation (reservation, request, etc.) + advertising_medium: Advertising medium + advertising_partner: Advertising partner + advertising_campagne: Advertising campaign + """ + self.hotel_id = hotel_id + self.reservation_id = reservation_id + self.reservation_number = reservation_number + self.reservation_date = reservation_date + self.creation_time = creation_time or f"{reservation_date}T00:00:00" + self.reservation_type = reservation_type + self.advertising_medium = advertising_medium + self.advertising_partner = advertising_partner + self.advertising_campagne = advertising_campagne + + self.guest_data: Optional[dict] = None + self.rooms: list[RoomReservationBuilder] = [] + + def set_guest( + self, + guest_id: str, + first_name: str, + last_name: str, + email: str, + language: str = "en", + gender: Optional[str] = None, + country_code: Optional[str] = None, + country: Optional[str] = None, + ) -> "ReservationXMLBuilder": + """Set guest information for the reservation. + + Args: + guest_id: Guest ID + first_name: Guest first name + last_name: Guest last name + email: Guest email + language: Guest language code + gender: Guest gender + country_code: Guest country code + country: Guest country name + + Returns: + Self for method chaining + """ + self.guest_data = { + "id": guest_id, + "firstName": first_name, + "lastName": last_name, + "email": email, + "language": language, + } + if gender: + self.guest_data["gender"] = gender + if country_code: + self.guest_data["countryCode"] = country_code + if country: + self.guest_data["country"] = country + + return self + + def add_room( + self, + arrival: str, + departure: str, + room_type: str = "DZV", + room_number: str = "101", + status: str = "reserved", + adults: int = 2, + children: int = 0, + infants: int = 0, + rate_plan_code: str = "STANDARD", + connected_room_type: str = "0", + revenue_logis_per_day: Optional[float] = None, + revenue_total_per_day: Optional[float] = None, + ) -> "ReservationXMLBuilder": + """Add a room reservation with convenient daily sales generation. + + Args: + arrival: Arrival date in YYYY-MM-DD format + departure: Departure date in YYYY-MM-DD format + room_type: Room type code + room_number: Room number + status: Reservation status + adults: Number of adults + children: Number of children + infants: Number of infants + rate_plan_code: Rate plan code + connected_room_type: Connected room type + revenue_logis_per_day: Fixed revenue per day (auto-generates dailySale entries) + revenue_total_per_day: Total revenue per day (defaults to revenue_logis_per_day) + + Returns: + Self for method chaining + """ + room_builder = RoomReservationBuilder( + arrival=arrival, + departure=departure, + room_type=room_type, + room_number=room_number, + status=status, + adults=adults, + children=children, + infants=infants, + rate_plan_code=rate_plan_code, + connected_room_type=connected_room_type, + revenue_logis_per_day=revenue_logis_per_day, + revenue_total_per_day=revenue_total_per_day, + ) + self.rooms.append(room_builder) + return self + + def add_room_builder( + self, room_builder: RoomReservationBuilder + ) -> "ReservationXMLBuilder": + """Add a pre-configured room builder. + + Args: + room_builder: RoomReservationBuilder instance + + Returns: + Self for method chaining + """ + self.rooms.append(room_builder) + return self + + def build(self) -> ET.Element: + """Build the reservation XML element. + + Returns: + XML Element for the reservation + """ + reservation_attrs = { + "hotelID": self.hotel_id, + "id": self.reservation_id, + "number": self.reservation_number, + "date": self.reservation_date, + "creationTime": self.creation_time, + "type": self.reservation_type, + } + + if self.advertising_medium: + reservation_attrs["advertisingMedium"] = self.advertising_medium + if self.advertising_partner: + reservation_attrs["advertisingPartner"] = self.advertising_partner + if self.advertising_campagne: + reservation_attrs["advertisingCampagne"] = self.advertising_campagne + + reservation_elem = ET.Element("reservation", reservation_attrs) + + # Add guest element + if self.guest_data: + ET.SubElement(reservation_elem, "guest", self.guest_data) + + # Add roomReservations + if self.rooms: + room_reservations_elem = ET.SubElement( + reservation_elem, "roomReservations" + ) + for room_builder in self.rooms: + room_elem = room_builder.build() + room_reservations_elem.append(room_elem) + + return reservation_elem + + def build_xml(self, include_xml_declaration: bool = True) -> str: + """Build the complete XML string for this reservation. + + Args: + include_xml_declaration: Whether to include declaration + + Returns: + XML string + """ + reservation_elem = self.build() + + # Wrap in root element + root = ET.Element("reservations") + root.append(reservation_elem) + + xml_str = ET.tostring(root, encoding="unicode") + + if include_xml_declaration: + xml_str = '\n' + xml_str + + return xml_str + + +class MultiReservationXMLBuilder: + """Builder for creating XML documents with multiple reservations. + + Example: + builder = MultiReservationXMLBuilder() + builder.add_reservation( + ReservationXMLBuilder(...).set_guest(...).add_room(...) + ) + builder.add_reservation( + ReservationXMLBuilder(...).set_guest(...).add_room(...) + ) + xml_string = builder.build_xml() + """ + + def __init__(self): + """Initialize multi-reservation builder.""" + self.reservations: list[ReservationXMLBuilder] = [] + + def add_reservation( + self, reservation_builder: ReservationXMLBuilder + ) -> "MultiReservationXMLBuilder": + """Add a reservation to the document. + + Args: + reservation_builder: ReservationXMLBuilder instance + + Returns: + Self for method chaining + """ + self.reservations.append(reservation_builder) + return self + + def build_xml(self, include_xml_declaration: bool = True) -> str: + """Build the complete XML string with all reservations. + + Args: + include_xml_declaration: Whether to include declaration + + Returns: + XML string with multiple reservations + """ + root = ET.Element("reservations") + + for reservation_builder in self.reservations: + reservation_elem = reservation_builder.build() + root.append(reservation_elem) + + xml_str = ET.tostring(root, encoding="unicode") + + if include_xml_declaration: + xml_str = '\n' + xml_str + + return xml_str diff --git a/tests/test_conversion_service.py b/tests/test_conversion_service.py index 8ee22ba..094b24e 100644 --- a/tests/test_conversion_service.py +++ b/tests/test_conversion_service.py @@ -340,13 +340,348 @@ class TestConversionServiceWithImportedData: assert stats["total_daily_sales"] == 0 assert stats["errors"] == 0 + @pytest.mark.asyncio + async def test_duplicate_reservations(self, test_db_session): + """Test that room entries are correctly updated when reservation status changes. + + This test detects a bug where ConversionRoom records are not properly upserted + when the same reservation is processed multiple times with different room numbers. + + Scenario: + 1. Process reservation with status='request', no revenue, room_number='101' + 2. Process reservation with status='reservation', with revenue, room_number='102' + 3. Swap: Process same reservations but reversed - first one now has status='reservation' + with room_number='201', second has status='request' with room_number='202' + 4. The old room entries (101, 102) should no longer exist in the database + """ + from tests.helpers import ReservationXMLBuilder, MultiReservationXMLBuilder + + # First batch: Process two reservations + multi_builder1 = MultiReservationXMLBuilder() + + # Reservation 1: Request status, no revenue, room 101 + res1_v1 = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="res_001", + reservation_number="RES-001", + reservation_date="2025-11-14", + reservation_type="request", + ) + .set_guest( + guest_id="guest_001", + first_name="Alice", + last_name="Johnson", + email="alice@example.com", + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-03", + room_number="101", + status="request", + # No revenue + ) + ) + multi_builder1.add_reservation(res1_v1) + + # Reservation 2: Reservation status, with revenue, room 102 + res2_v1 = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="res_002", + reservation_number="RES-002", + reservation_date="2025-11-15", + reservation_type="reservation", + ) + .set_guest( + guest_id="guest_002", + first_name="Bob", + last_name="Smith", + email="bob@example.com", + ) + .add_room( + arrival="2025-12-10", + departure="2025-12-12", + room_number="102", + status="reserved", + revenue_logis_per_day=150.0, + ) + ) + multi_builder1.add_reservation(res2_v1) + + xml_content1 = multi_builder1.build_xml() + + # Process first batch + service = ConversionService(test_db_session) + stats1 = await service.process_conversion_xml(xml_content1) + + assert stats1["total_reservations"] == 2 + + # Verify rooms exist in database + result = await test_db_session.execute( + select(ConversionRoom).where(ConversionRoom.room_number == "101") + ) + room_101 = result.scalar_one_or_none() + assert room_101 is not None, "Room 101 should exist after first processing" + + result = await test_db_session.execute( + select(ConversionRoom).where(ConversionRoom.room_number == "102") + ) + room_102 = result.scalar_one_or_none() + assert room_102 is not None, "Room 102 should exist after first processing" + + # Second batch: Swap the reservations and change room numbers + multi_builder2 = MultiReservationXMLBuilder() + + # Reservation 1: NOW has reservation status, with revenue, room 201 (changed from 101) + res1_v2 = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="res_001", # Same ID + reservation_number="RES-001", # Same number + reservation_date="2025-11-14", + reservation_type="reservation", # Changed from request + ) + .set_guest( + guest_id="guest_001", + first_name="Alice", + last_name="Johnson", + email="alice@example.com", + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-03", + room_number="201", # Changed from 101 + status="reserved", + revenue_logis_per_day=200.0, # Now has revenue + ) + ) + multi_builder2.add_reservation(res1_v2) + + # Reservation 2: NOW has request status, no revenue, room 202 (changed from 102) + res2_v2 = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="res_002", # Same ID + reservation_number="RES-002", # Same number + reservation_date="2025-11-15", + reservation_type="request", # Changed from reservation + ) + .set_guest( + guest_id="guest_002", + first_name="Bob", + last_name="Smith", + email="bob@example.com", + ) + .add_room( + arrival="2025-12-10", + departure="2025-12-12", + room_number="202", # Changed from 102 + status="request", + # No revenue anymore + ) + ) + multi_builder2.add_reservation(res2_v2) + + xml_content2 = multi_builder2.build_xml() + + # Process second batch + stats2 = await service.process_conversion_xml(xml_content2) + + assert stats2["total_reservations"] == 2 + + # BUG DETECTION: Old room entries (101, 102) should NOT exist anymore + # They should have been replaced by new room entries (201, 202) + + result = await test_db_session.execute( + select(ConversionRoom).where(ConversionRoom.room_number == "101") + ) + room_101_after = result.scalar_one_or_none() + assert room_101_after is None, ( + "BUG: Room 101 should no longer exist after reprocessing with room 201. " + "Old room entries are not being removed when reservation is updated." + ) + + result = await test_db_session.execute( + select(ConversionRoom).where(ConversionRoom.room_number == "102") + ) + room_102_after = result.scalar_one_or_none() + assert room_102_after is None, ( + "BUG: Room 102 should no longer exist after reprocessing with room 202. " + "Old room entries are not being removed when reservation is updated." + ) + + # New room entries should exist + result = await test_db_session.execute( + select(ConversionRoom).where(ConversionRoom.room_number == "201") + ) + room_201 = result.scalar_one_or_none() + assert room_201 is not None, "Room 201 should exist after second processing" + + result = await test_db_session.execute( + select(ConversionRoom).where(ConversionRoom.room_number == "202") + ) + room_202 = result.scalar_one_or_none() + assert room_202 is not None, "Room 202 should exist after second processing" + + # Verify we only have 2 conversion room records total (not 4) + result = await test_db_session.execute(select(ConversionRoom)) + all_rooms = result.scalars().all() + assert len(all_rooms) == 2, ( + f"BUG: Expected 2 conversion rooms total, but found {len(all_rooms)}. " + f"Old room entries are not being deleted. Room numbers: {[r.room_number for r in all_rooms]}" + ) + + + +class TestXMLBuilderUsage: + """Demonstrate usage of XML builder helpers for creating test data.""" + + @pytest.mark.asyncio + async def test_using_xml_builder_for_simple_reservation(self, test_db_session): + """Example: Create a simple reservation using the XML builder helper.""" + from tests.helpers import ReservationXMLBuilder + + # Build a reservation with convenient fluent API + xml_content = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="test_123", + reservation_number="RES-123", + reservation_date="2025-11-14", + ) + .set_guest( + guest_id="guest_001", + first_name="John", + last_name="Doe", + email="john@example.com", + country_code="US", + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-05", + room_type="DZV", + room_number="101", + revenue_logis_per_day=150.0, + adults=2 + ) + .build_xml() + ) + + # Process the XML + service = ConversionService(test_db_session) + stats = await service.process_conversion_xml(xml_content) + + assert stats["total_reservations"] == 1 + assert stats["total_daily_sales"] == 5 # 4 nights + departure day + + @pytest.mark.asyncio + async def test_using_xml_builder_for_multi_room_reservation( + self, test_db_session + ): + """Example: Create a reservation with multiple rooms.""" + from tests.helpers import ReservationXMLBuilder + + xml_content = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="test_456", + reservation_number="RES-456", + reservation_date="2025-11-14", + ) + .set_guest( + guest_id="guest_002", + first_name="Jane", + last_name="Smith", + email="jane@example.com", + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-05", + room_number="101", + revenue_logis_per_day=150.0, + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-05", + room_number="102", + revenue_logis_per_day=200.0, + ) + .build_xml() + ) + + service = ConversionService(test_db_session) + stats = await service.process_conversion_xml(xml_content) + + assert stats["total_reservations"] == 1 + # 2 rooms × 5 daily sales each = 10 total + assert stats["total_daily_sales"] == 10 + + @pytest.mark.asyncio + async def test_using_multi_reservation_builder(self, test_db_session): + """Example: Create multiple reservations in one XML document.""" + from tests.helpers import ReservationXMLBuilder, MultiReservationXMLBuilder + + multi_builder = MultiReservationXMLBuilder() + + # Add first reservation + res1 = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="test_001", + reservation_number="RES-001", + reservation_date="2025-11-14", + ) + .set_guest( + guest_id="guest_001", + first_name="Alice", + last_name="Johnson", + email="alice@example.com", + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-03", + revenue_logis_per_day=100.0, + ) + ) + multi_builder.add_reservation(res1) + + # Add second reservation + res2 = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="test_002", + reservation_number="RES-002", + reservation_date="2025-11-15", + ) + .set_guest( + guest_id="guest_002", + first_name="Bob", + last_name="Williams", + email="bob@example.com", + ) + .add_room( + arrival="2025-12-10", + departure="2025-12-12", + revenue_logis_per_day=150.0, + ) + ) + multi_builder.add_reservation(res2) + + xml_content = multi_builder.build_xml() + + # Process the XML + service = ConversionService(test_db_session) + stats = await service.process_conversion_xml(xml_content) + + assert stats["total_reservations"] == 2 + # Res1: 3 days (2 nights), Res2: 3 days (2 nights) = 6 total + assert stats["total_daily_sales"] == 6 + class TestHashedMatchingLogic: """Test the hashed matching logic used in ConversionService.""" - - - @pytest.mark.asyncio async def test_conversion_guest_hashed_fields_are_populated( self, test_db_session diff --git a/tests/test_xml_builders.py b/tests/test_xml_builders.py new file mode 100644 index 0000000..984b478 --- /dev/null +++ b/tests/test_xml_builders.py @@ -0,0 +1,327 @@ +"""Tests for XML builder helpers.""" + +import pytest +from xml.etree import ElementTree as ET + +from tests.helpers.xml_builders import ( + ReservationXMLBuilder, + MultiReservationXMLBuilder, + RoomReservationBuilder, +) + + +class TestRoomReservationBuilder: + """Test RoomReservationBuilder functionality.""" + + def test_basic_room_without_revenue(self): + """Test creating a basic room reservation without revenue.""" + builder = RoomReservationBuilder( + arrival="2025-12-01", + departure="2025-12-03", + room_type="DZV", + room_number="101", + ) + + elem = builder.build() + + assert elem.tag == "roomReservation" + assert elem.get("arrival") == "2025-12-01" + assert elem.get("departure") == "2025-12-03" + assert elem.get("roomType") == "DZV" + assert elem.get("roomNumber") == "101" + + # Check daily sales - should have 3 entries (12-01, 12-02, 12-03) + daily_sales = elem.find("dailySales") + assert daily_sales is not None + daily_sale_elements = daily_sales.findall("dailySale") + assert len(daily_sale_elements) == 3 + + # First two should have no revenue attributes + assert daily_sale_elements[0].get("revenueTotal") is None + assert daily_sale_elements[0].get("revenueLogis") is None + + def test_room_with_revenue(self): + """Test creating a room with revenue per day.""" + builder = RoomReservationBuilder( + arrival="2025-12-01", + departure="2025-12-03", + room_type="DZV", + room_number="101", + revenue_logis_per_day=150.0, + ) + + elem = builder.build() + daily_sales = elem.find("dailySales") + daily_sale_elements = daily_sales.findall("dailySale") + + # Should have 3 entries total + assert len(daily_sale_elements) == 3 + + # First two days should have revenue + assert daily_sale_elements[0].get("revenueTotal") == "150.0" + assert daily_sale_elements[0].get("revenueLogis") == "150.0" + assert daily_sale_elements[1].get("revenueTotal") == "150.0" + assert daily_sale_elements[1].get("revenueLogis") == "150.0" + + # Departure day should have no revenue + assert daily_sale_elements[2].get("revenueTotal") is None + assert daily_sale_elements[2].get("revenueLogis") is None + + def test_room_with_children_and_infants(self): + """Test room with children and infants attributes.""" + builder = RoomReservationBuilder( + arrival="2025-12-01", + departure="2025-12-02", + adults=2, + children=1, + infants=1, + ) + + elem = builder.build() + assert elem.get("adults") == "2" + assert elem.get("children") == "1" + assert elem.get("infants") == "1" + + +class TestReservationXMLBuilder: + """Test ReservationXMLBuilder functionality.""" + + def test_basic_reservation(self): + """Test creating a basic reservation with one room.""" + builder = ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12345", + reservation_number="RES-001", + reservation_date="2025-11-14", + ) + builder.set_guest( + guest_id="guest_001", + first_name="John", + last_name="Doe", + email="john@example.com", + ) + builder.add_room( + arrival="2025-12-01", + departure="2025-12-05", + revenue_logis_per_day=150.0, + ) + + xml_string = builder.build_xml() + + # Parse and verify structure + root = ET.fromstring(xml_string) + assert root.tag == "reservations" + + reservation = root.find("reservation") + assert reservation is not None + assert reservation.get("hotelID") == "39054_001" + assert reservation.get("id") == "12345" + assert reservation.get("number") == "RES-001" + + guest = reservation.find("guest") + assert guest is not None + assert guest.get("firstName") == "John" + assert guest.get("lastName") == "Doe" + assert guest.get("email") == "john@example.com" + + room_reservations = reservation.find("roomReservations") + assert room_reservations is not None + rooms = room_reservations.findall("roomReservation") + assert len(rooms) == 1 + + def test_reservation_with_multiple_rooms(self): + """Test reservation with multiple rooms.""" + builder = ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12345", + reservation_number="RES-001", + reservation_date="2025-11-14", + ) + builder.set_guest( + guest_id="guest_001", + first_name="John", + last_name="Doe", + email="john@example.com", + ) + builder.add_room( + arrival="2025-12-01", + departure="2025-12-05", + room_number="101", + revenue_logis_per_day=150.0, + ) + builder.add_room( + arrival="2025-12-01", + departure="2025-12-05", + room_number="102", + revenue_logis_per_day=200.0, + ) + + xml_string = builder.build_xml() + root = ET.fromstring(xml_string) + + reservation = root.find("reservation") + room_reservations = reservation.find("roomReservations") + rooms = room_reservations.findall("roomReservation") + + assert len(rooms) == 2 + assert rooms[0].get("roomNumber") == "101" + assert rooms[1].get("roomNumber") == "102" + + def test_reservation_with_advertising_data(self): + """Test reservation with advertising campaign data.""" + builder = ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12345", + reservation_number="RES-001", + reservation_date="2025-11-14", + advertising_medium="99TALES", + advertising_partner="google", + advertising_campagne="EAIaIQobChMI...", + ) + builder.set_guest( + guest_id="guest_001", + first_name="John", + last_name="Doe", + email="john@example.com", + ) + builder.add_room( + arrival="2025-12-01", + departure="2025-12-05", + ) + + xml_string = builder.build_xml() + root = ET.fromstring(xml_string) + + reservation = root.find("reservation") + assert reservation.get("advertisingMedium") == "99TALES" + assert reservation.get("advertisingPartner") == "google" + assert reservation.get("advertisingCampagne") == "EAIaIQobChMI..." + + +class TestMultiReservationXMLBuilder: + """Test MultiReservationXMLBuilder functionality.""" + + def test_multiple_reservations(self): + """Test creating XML with multiple reservations.""" + multi_builder = MultiReservationXMLBuilder() + + # Add first reservation + res1 = ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12345", + reservation_number="RES-001", + reservation_date="2025-11-14", + ) + res1.set_guest( + guest_id="guest_001", + first_name="John", + last_name="Doe", + email="john@example.com", + ) + res1.add_room( + arrival="2025-12-01", + departure="2025-12-03", + revenue_logis_per_day=150.0, + ) + multi_builder.add_reservation(res1) + + # Add second reservation + res2 = ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12346", + reservation_number="RES-002", + reservation_date="2025-11-15", + ) + res2.set_guest( + guest_id="guest_002", + first_name="Jane", + last_name="Smith", + email="jane@example.com", + ) + res2.add_room( + arrival="2025-12-10", + departure="2025-12-12", + revenue_logis_per_day=200.0, + ) + multi_builder.add_reservation(res2) + + xml_string = multi_builder.build_xml() + root = ET.fromstring(xml_string) + + assert root.tag == "reservations" + reservations = root.findall("reservation") + assert len(reservations) == 2 + assert reservations[0].get("id") == "12345" + assert reservations[1].get("id") == "12346" + + +class TestConvenienceFeatures: + """Test convenience features for common test scenarios.""" + + def test_simple_one_liner_reservation(self): + """Test creating a simple reservation in a fluent style.""" + xml = ( + ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12345", + reservation_number="RES-001", + reservation_date="2025-11-14", + ) + .set_guest( + guest_id="guest_001", + first_name="John", + last_name="Doe", + email="john@example.com", + ) + .add_room( + arrival="2025-12-01", + departure="2025-12-05", + revenue_logis_per_day=160.0, + ) + .build_xml() + ) + + assert '' in xml + assert 'hotelID="39054_001"' in xml + assert 'revenueLogis="160.0"' in xml + + def test_revenue_calculation_for_multi_day_stay(self): + """Test that daily sales are correctly generated for multi-day stays.""" + builder = ReservationXMLBuilder( + hotel_id="39054_001", + reservation_id="12345", + reservation_number="RES-001", + reservation_date="2025-11-14", + ) + builder.set_guest( + guest_id="guest_001", + first_name="John", + last_name="Doe", + email="john@example.com", + ) + # 7-day stay (June 25 - July 2, 7 nights) + builder.add_room( + arrival="2026-06-25", + departure="2026-07-02", + revenue_logis_per_day=160.0, + ) + + elem = builder.build() + room_reservations = elem.find("roomReservations") + room = room_reservations.find("roomReservation") + daily_sales = room.find("dailySales") + daily_sale_elements = daily_sales.findall("dailySale") + + # Should have 8 daily sale entries (7 nights + departure day) + assert len(daily_sale_elements) == 8 + + # First 7 should have revenue + for i in range(7): + assert daily_sale_elements[i].get("revenueLogis") == "160.0" + + # Departure day should not have revenue + assert daily_sale_elements[7].get("revenueLogis") is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])