diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..d36276a
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,139 @@
+# AlpineBits Python Server - AI Agent Instructions
+
+## Project Overview
+
+This is an **AlpineBits 2024-10 server** that bridges booking requests from Wix landing pages to hotel partners. It's a dual-purpose system:
+
+1. **FastAPI webhook receiver** - accepts booking forms from wix.com landing pages via `/api/webhook/wix-form`
+2. **AlpineBits OTA server** - exposes hotel reservation data at `/api/alpinebits/server-2024-10` using OpenTravel Alliance XML protocol
+
+Data flows: Wix form → Database → AlpineBits XML → Hotel systems (pull or push)
+
+## Architecture Patterns
+
+### XML Generation with xsdata
+
+- **Never manually construct XML strings**. Use xsdata-generated Pydantic dataclasses from `src/alpine_bits_python/generated/alpinebits.py`
+- Parse XML: `XmlParser().from_string(xml_string, OtaPingRq)`
+- Serialize XML: `XmlSerializer(config=SerializerConfig(...)).render(ota_object)`
+- Factory pattern: Use classes in `alpine_bits_helpers.py` (e.g., `CustomerFactory`, `GuestCountsFactory`) to build complex OTA objects from DB models
+- Example: `create_res_retrieve_response()` builds OTA_ResRetrieveRS from `(Reservation, Customer)` tuples
+- **Regenerating XML classes**: Run `xsdata` on `AlpineBits-HotelData-2024-10/files/schema-xsd/alpinebits.xsd` to regenerate `generated/alpinebits.py` (only if XSD spec changes)
+
+### Configuration System
+
+- Config loaded from YAML with secret injection via `!secret` tags (see `config_loader.py`)
+- Default config location: `config/config.yaml` + `config/secrets.yaml`
+- Override via `ALPINEBITS_CONFIG_DIR` environment variable
+- Multi-hotel support: Each hotel in `alpine_bits_auth` array gets own credentials and optional `push_endpoint`
+
+### Database Layer
+
+- **Async-only SQLAlchemy** with `AsyncSession` (see `db.py`)
+- Three core tables: `Customer`, `Reservation`, `AckedRequest` (tracks which clients acknowledged which reservations)
+- DB URL configurable: SQLite for dev (`sqlite+aiosqlite:///alpinebits.db`), PostgreSQL for prod
+- Database auto-created on startup in `api.py:create_app()`
+
+### Event-Driven Push System
+
+- `EventDispatcher` in `api.py` enables hotel-specific listeners: `event_dispatcher.register_hotel_listener("reservation:created", hotel_code, push_listener)`
+- Push listener sends OTA_HotelResNotif XML to hotel's configured `push_endpoint.url` with Bearer token auth
+- Push requests logged to `logs/push_requests/` with timestamp and unique ID
+- **Note**: Push endpoint support is currently dormant - configured but not actively used by partners
+
+### AlpineBits Action Pattern
+
+- Each OTA action is a class inheriting `AlpineBitsActionHandler` (see `alpinebits_server.py`)
+- Actions: `PingAction`, `ReadAction`, `NotifReportAction`, `PushAction`
+- Request flow: Parse XML → Call `handle()` → Return `AlpineBitsActionResult` with XML response + HTTP status
+- `AlpineBitsActionName` enum maps capability names to request names (e.g., `OTA_READ` → `"OTA_Read:GuestRequests"`)
+- Server supports multiple AlpineBits versions (2024-10, 2022-10) when actions are identical across versions
+
+### Acknowledgment System
+
+- `AckedRequest` table tracks which clients acknowledged which reservations via `OTA_NotifReport:GuestRequests`
+- Read requests filter out acknowledged reservations for clients with `client_id`
+- Prevents duplicate reservation sends: once acknowledged, data won't appear in subsequent reads for that client
+
+## Critical Workflows
+
+### Running Locally
+
+```bash
+uv sync # Install dependencies (uses uv, not pip!)
+uv run python -m alpine_bits_python.run_api # Start server on port 8080, clears DB on startup
+```
+
+### Testing
+
+```bash
+uv run pytest # Run all tests
+uv run pytest tests/test_alpine_bits_server_read.py # Specific test file
+```
+
+- Tests use in-memory SQLite via `test_db_engine` fixture (see `tests/test_alpine_bits_server_read.py`)
+- Test data fixtures in `tests/test_data/` directory
+
+### Building for Deployment
+
+```bash
+uv sync
+docker build . -t gitea.linter-home.com/jonas/asa_api:master
+```
+
+- Multi-stage Dockerfile: builder stage installs deps with uv, production stage copies `.venv`
+- Runs as non-root user (UID 1000) for security
+- Requires `ALPINEBITS_CONFIG_DIR=/config` volume mount for config files
+- **Deployment**: Docker build pipeline exists and works; can also build manually on target system
+
+## Project-Specific Conventions
+
+### Naming Patterns
+
+- OTA message types use full AlpineBits names: `OtaReadRq`, `OtaResRetrieveRs`, `OtaHotelResNotifRq`
+- Factory classes suffix with `Factory`: `CustomerFactory`, `HotelReservationIdFactory`
+- DB models in `db.py`, validation schemas in `schemas.py`, OTA helpers in `alpine_bits_helpers.py`
+
+### Data Validation Flow
+
+1. **API Layer** → Pydantic schemas (`schemas.py`) validate incoming data
+2. **DB Layer** → SQLAlchemy models (`db.py`) persist validated data
+3. **XML Layer** → xsdata classes (`generated/alpinebits.py`) + factories (`alpine_bits_helpers.py`) generate OTA XML
+
+This separation prevents mixing concerns (validation ≠ persistence ≠ XML generation).
+
+### Unique ID Generation
+
+- Reservation IDs: 35-char max, format `{hotel_code}_{uuid4}_{timestamp}`
+- Generated via `generate_unique_id()` in `auth.py`
+
+### Rate Limiting
+
+- Uses `slowapi` with Redis backend
+- Three tiers: `DEFAULT_RATE_LIMIT` (100/hour), `WEBHOOK_RATE_LIMIT` (300/hour), `BURST_RATE_LIMIT` (10/minute)
+- Applied via decorators: `@limiter.limit(DEFAULT_RATE_LIMIT)`
+
+## Common Pitfalls
+
+1. **Don't use synchronous SQLAlchemy calls** - Always `await session.execute()`, never `session.query()`
+2. **Don't hardcode XML namespaces** - Let xsdata handle them via generated classes
+3. **Don't skip config validation** - Voluptuous schemas in `config_loader.py` catch config errors early
+4. **Auth is per-hotel** - HTTP Basic Auth credentials from `alpine_bits_auth` config array
+5. **AlpineBits version matters** - Server implements 2024-10 spec (see `AlpineBits-HotelData-2024-10/` directory)
+
+## Key Files Reference
+
+- `api.py` - FastAPI app, all endpoints, event dispatcher
+- `alpinebits_server.py` - AlpineBits action handlers (Ping, Read, NotifReport)
+- `alpine_bits_helpers.py` - Factory classes for building OTA XML from DB models
+- `config_loader.py` - YAML config loading with secret injection
+- `db.py` - SQLAlchemy async models (Customer, Reservation, AckedRequest)
+- `schemas.py` - Pydantic validation schemas
+- `generated/alpinebits.py` - xsdata-generated OTA XML classes (DO NOT EDIT - regenerate from XSD)
+
+## Testing Strategy
+
+- Fixtures create isolated in-memory databases per test
+- Use `test_config()` fixture for test configuration
+- XML serialization/parsing tested via xsdata round-trips
+- Push endpoint mocking via httpx in tests
diff --git a/logs/push_requests/alpinebits_push_12345_8e68dab6-7c2e-4c67-9471-b8cbfb7b3fcb_20251007_171338.xml b/logs/push_requests/alpinebits_push_12345_8e68dab6-7c2e-4c67-9471-b8cbfb7b3fcb_20251007_171338.xml
new file mode 100644
index 0000000..b3b5a76
--- /dev/null
+++ b/logs/push_requests/alpinebits_push_12345_8e68dab6-7c2e-4c67-9471-b8cbfb7b3fcb_20251007_171338.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Frau
+ Christine
+ Niederkofler
+
+
+ info@ledermode.at
+
+
+
+
+
+
+
+
+
+ Angebot/Offerta: Törggelewochen - Herbstliche Genüsse & Südtiroler Tradition
+
+
+ Hallo. Wir würden gerne mit unseren Mitarbeitern vom 25.10 - 26.10.25 nach Südtirol fahren.
+Geplant wäre am Samstagabend Törggelen und am Sonntag nach dem Frühstück mit der Gondel zur Seiser Alm zu fahren.
+Wir sind ca. 13 Personen (es können gerne auch 3-Bettzimmer dabei sein falls vorhanden. Sonst DZ und wir benötigen 1 EZ).
+Bitte um ein Angebot für Törggelen, Übernachtung und Frühstück. Vielen lieben Dank! Christine Niederkofler
+
+
+
+
+
+
+
+
+
+ 99tales GmbH
+
+
+
+
+
+
+
+
+
diff --git a/logs/push_requests/alpinebits_push_12345_c52702c9-55b9-44e1-b158-ec9544c73cc7_20251007_163252.xml b/logs/push_requests/alpinebits_push_12345_c52702c9-55b9-44e1-b158-ec9544c73cc7_20251007_163252.xml
new file mode 100644
index 0000000..deecd54
--- /dev/null
+++ b/logs/push_requests/alpinebits_push_12345_c52702c9-55b9-44e1-b158-ec9544c73cc7_20251007_163252.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Frau
+ Genesia
+ Supino
+
+
+ supinogenesia@gmail.com
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 99tales GmbH
+
+
+
+
+
+
+
+
+
diff --git a/logs/push_requests/alpinebits_push_12345_c52702c9-55b9-44e1-b158-ec9544c73cc7_20251007_171225.xml b/logs/push_requests/alpinebits_push_12345_c52702c9-55b9-44e1-b158-ec9544c73cc7_20251007_171225.xml
new file mode 100644
index 0000000..1f9f2d1
--- /dev/null
+++ b/logs/push_requests/alpinebits_push_12345_c52702c9-55b9-44e1-b158-ec9544c73cc7_20251007_171225.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Frau
+ Genesia
+ Supino
+
+
+ supinogenesia@gmail.com
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 99tales GmbH
+
+
+
+
+
+
+
+
+
diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py
index 3804ea0..0d82cf9 100644
--- a/src/alpine_bits_python/alpine_bits_helpers.py
+++ b/src/alpine_bits_python/alpine_bits_helpers.py
@@ -600,7 +600,9 @@ class AlpineBitsFactory:
return None
-def create_res_retrieve_response(list: list[tuple[Reservation, Customer]]):
+def create_res_retrieve_response(
+ list: list[tuple[Reservation, Customer]],
+) -> OtaResRetrieveRs:
"""Create RetrievedReservation XML from database entries."""
return _create_xml_from_db(list, OtaMessageType.RETRIEVE)
@@ -833,12 +835,12 @@ def _create_xml_from_db(
_LOGGER.debug(traceback.format_exc())
if type == OtaMessageType.NOTIF:
- retrieved_reservations = OtaHotelResNotifRq.HotelReservations(
+ res_list_obj = OtaHotelResNotifRq.HotelReservations(
hotel_reservation=reservations_list
)
ota_hotel_res_notif_rq = OtaHotelResNotifRq(
- version="7.000", hotel_reservations=retrieved_reservations
+ version="7.000", hotel_reservations=res_list_obj
)
try:
@@ -849,12 +851,12 @@ def _create_xml_from_db(
return ota_hotel_res_notif_rq
if type == OtaMessageType.RETRIEVE:
- retrieved_reservations = OtaResRetrieveRs.ReservationsList(
+ res_list_obj = OtaResRetrieveRs.ReservationsList(
hotel_reservation=reservations_list
)
ota_res_retrieve_rs = OtaResRetrieveRs(
- version="7.000", success="", reservations_list=retrieved_reservations
+ version="7.000", success="", reservations_list=res_list_obj
)
try:
diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py
index ae880cb..4fb0e60 100644
--- a/src/alpine_bits_python/alpinebits_server.py
+++ b/src/alpine_bits_python/alpinebits_server.py
@@ -202,8 +202,7 @@ class AlpineBitsAction(ABC):
class ServerCapabilities:
- """Automatically discovers AlpineBitsAction implementations and generates capabilities.
- """
+ """Automatically discovers AlpineBitsAction implementations and generates capabilities."""
def __init__(self):
self.action_registry: dict[AlpineBitsActionName, type[AlpineBitsAction]] = {}
@@ -237,9 +236,7 @@ class ServerCapabilities:
return False
def create_capabilities_dict(self) -> None:
- """Generate the capabilities dictionary based on discovered actions.
-
- """
+ """Generate the capabilities dictionary based on discovered actions."""
versions_dict = {}
for action_enum, action_class in self.action_registry.items():
@@ -287,10 +284,8 @@ class ServerCapabilities:
if action.get("action") != "action_OTA_Ping"
]
-
def get_capabilities_dict(self) -> dict:
- """Get capabilities as a dictionary. Generates if not already created.
- """
+ """Get capabilities as a dictionary. Generates if not already created."""
if self.capability_dict is None:
self.create_capabilities_dict()
return self.capability_dict
@@ -615,7 +610,9 @@ class NotifReportReadAction(AlpineBitsAction):
)
timestamp = datetime.now(ZoneInfo("UTC"))
- for entry in notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation: # type: ignore
+ for entry in (
+ notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation
+ ): # type: ignore
unique_id = entry.unique_id.id
acked_request = AckedRequest(
unique_id=unique_id,
diff --git a/tests/test_alpine_bits_server_read.py b/tests/test_alpine_bits_server_read.py
index 139597f..54fa608 100644
--- a/tests/test_alpine_bits_server_read.py
+++ b/tests/test_alpine_bits_server_read.py
@@ -1,2 +1,451 @@
+"""Tests for AlpineBits server read action.
+
+This module tests the ReadAction handler which retrieves reservations
+from the database and returns them as OTA_ResRetrieveRS XML.
+"""
+
+from datetime import UTC, date, datetime
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from xsdata.formats.dataclass.parsers import XmlParser
+from xsdata.formats.dataclass.serializers import XmlSerializer
+from xsdata.formats.dataclass.serializers.config import SerializerConfig
+
+from alpine_bits_python.alpine_bits_helpers import create_res_retrieve_response
+from alpine_bits_python.alpinebits_server import AlpineBitsClientInfo
+from alpine_bits_python.db import Base, Customer, Reservation
+from alpine_bits_python.generated import OtaReadRq
+from alpine_bits_python.generated.alpinebits import OtaResRetrieveRs
+@pytest.fixture
+async def test_db_engine():
+ """Create an in-memory SQLite database for testing."""
+ engine = create_async_engine(
+ "sqlite+aiosqlite:///:memory:",
+ echo=False,
+ )
+
+ # Create tables
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ yield engine
+
+ # Cleanup
+ await engine.dispose()
+
+
+@pytest.fixture
+async def test_db_session(test_db_engine):
+ """Create a test database session."""
+ async_session = async_sessionmaker(
+ test_db_engine,
+ class_=AsyncSession,
+ expire_on_commit=False,
+ )
+
+ async with async_session() as session:
+ yield session
+
+
+@pytest.fixture
+def sample_customer():
+ """Create a sample customer for testing."""
+ return Customer(
+ id=1,
+ given_name="John",
+ surname="Doe",
+ contact_id="CONTACT-12345",
+ name_prefix="Mr.",
+ name_title="Jr.",
+ email_address="john.doe@example.com",
+ phone="+1234567890",
+ email_newsletter=True,
+ address_line="123 Main Street",
+ city_name="Anytown",
+ postal_code="12345",
+ country_code="US",
+ gender="Male",
+ birth_date="1980-01-01",
+ language="en",
+ address_catalog=False,
+ )
+
+
+@pytest.fixture
+def sample_reservation(sample_customer):
+ """Create a sample reservation for testing."""
+ return Reservation(
+ id=1,
+ customer_id=1,
+ unique_id="RES-2024-001",
+ start_date=date(2024, 12, 25),
+ end_date=date(2024, 12, 31),
+ num_adults=2,
+ num_children=1,
+ children_ages="8",
+ offer="Christmas Special",
+ created_at=datetime.now(UTC),
+ utm_source="google",
+ utm_medium="cpc",
+ utm_campaign="winter2024",
+ utm_term="ski resort",
+ utm_content="ad1",
+ user_comment="Late check-in requested",
+ fbclid="",
+ gclid="abc123xyz",
+ hotel_code="HOTEL123",
+ hotel_name="Alpine Paradise Resort",
+ customer=sample_customer,
+ )
+
+
+@pytest.fixture
+def minimal_customer():
+ """Create a minimal customer with only required fields."""
+ return Customer(
+ id=2,
+ given_name="Jane",
+ surname="Smith",
+ contact_id="CONTACT-67890",
+ )
+
+
+@pytest.fixture
+def minimal_reservation(minimal_customer):
+ """Create a minimal reservation with only required fields."""
+ return Reservation(
+ id=2,
+ customer_id=2,
+ unique_id="RES-2024-002",
+ start_date=date(2025, 1, 15),
+ end_date=date(2025, 1, 20),
+ num_adults=1,
+ num_children=0,
+ children_ages="",
+ hotel_code="HOTEL123",
+ hotel_name="Alpine Paradise Resort",
+ created_at=datetime.now(UTC),
+ customer=minimal_customer,
+ )
+
+
+@pytest.fixture
+def read_request_xml():
+ """Sample OTA_ReadRQ XML request."""
+ return """
+
+
+
+
+
+
+"""
+
+
+@pytest.fixture
+def read_request_xml_no_date_filter():
+ """Sample OTA_ReadRQ XML request without date filter."""
+ return """
+
+
+
+
+"""
+
+
+@pytest.fixture
+def test_config():
+ """Test configuration with hotel credentials."""
+ return {
+ "hotels": [
+ {
+ "hotel_id": "HOTEL123",
+ "username": "testuser",
+ "password": "testpass",
+ }
+ ]
+ }
+
+
+@pytest.fixture
+def client_info():
+ """Sample client info for testing."""
+ return AlpineBitsClientInfo(
+ username="testuser",
+ password="testpass",
+ client_id="CLIENT-001",
+ )
+
+
+class TestCreateResRetrieveResponse:
+ """Test the create_res_retrieve_response function."""
+
+ def test_empty_list(self):
+ """Test creating response with empty reservation list."""
+ response = create_res_retrieve_response([])
+
+ assert response is not None, "Response should not be None"
+
+ # check that response is of correct type
+ assert isinstance(response, OtaResRetrieveRs), (
+ "Response should be of type OtaResRetrieveRs"
+ )
+
+ assert hasattr(response, "success"), "Response should have success attribute"
+
+ assert hasattr(response, "reservations_list"), (
+ "Response should have reservations_list attribute"
+ )
+
+ def test_single_reservation(self, sample_reservation, sample_customer):
+ """Test creating response with single reservation."""
+ reservation_pairs = [(sample_reservation, sample_customer)]
+ response = create_res_retrieve_response(reservation_pairs)
+
+ assert response is not None
+ assert hasattr(response, "reservations_list"), (
+ "Response should have reservations_list attribute"
+ )
+
+ assert hasattr(response.reservations_list, "reservation"), (
+ "reservations_list should have reservation attribute"
+ )
+ # Verify the response can be serialized to XML
+ config = SerializerConfig(
+ pretty_print=True, xml_declaration=True, encoding="UTF-8"
+ )
+ serializer = XmlSerializer(config=config)
+ xml_output = serializer.render(
+ response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
+ )
+
+ assert xml_output is not None
+ assert b"RES-2024-001" in xml_output
+ assert b"John" in xml_output
+ assert b"Doe" in xml_output
+ assert b"HOTEL123" in xml_output
+
+ def test_multiple_reservations(
+ self,
+ sample_reservation,
+ sample_customer,
+ minimal_reservation,
+ minimal_customer,
+ ):
+ """Test creating response with multiple reservations."""
+ reservation_pairs = [
+ (sample_reservation, sample_customer),
+ (minimal_reservation, minimal_customer),
+ ]
+ response = create_res_retrieve_response(reservation_pairs)
+
+ assert response is not None
+
+ # Serialize to XML and verify both reservations are present
+ config = SerializerConfig(
+ pretty_print=True, xml_declaration=True, encoding="UTF-8"
+ )
+ serializer = XmlSerializer(config=config)
+ xml_output = serializer.render(
+ response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
+ )
+
+ assert b"RES-2024-001" in xml_output
+ assert b"RES-2024-002" in xml_output
+ assert b"John" in xml_output
+ assert b"Jane" in xml_output
+
+ def test_reservation_with_children(self, sample_reservation, sample_customer):
+ """Test reservation with children ages."""
+ sample_reservation.num_children = 2
+ sample_reservation.children_ages = "8,5"
+
+ reservation_pairs = [(sample_reservation, sample_customer)]
+ response = create_res_retrieve_response(reservation_pairs)
+
+ config = SerializerConfig(pretty_print=True)
+ serializer = XmlSerializer(config=config)
+ xml_output = serializer.render(
+ response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
+ )
+
+ assert response is not None
+ # Children should be represented in guest counts
+ assert b"GuestCount" in xml_output or b"Child" in xml_output
+
+
+class TestXMLParsing:
+ """Test XML parsing and generation."""
+
+ def test_parse_read_request(self, read_request_xml):
+ """Test parsing of OTA_ReadRQ XML."""
+ parser = XmlParser()
+ read_request = parser.from_string(read_request_xml, OtaReadRq)
+
+ assert read_request is not None
+ assert read_request.read_requests is not None
+ assert read_request.read_requests.hotel_read_request is not None
+
+ hotel_req = read_request.read_requests.hotel_read_request
+ assert hotel_req.hotel_code == "HOTEL123"
+ assert hotel_req.hotel_name == "Alpine Paradise Resort"
+ assert hotel_req.selection_criteria is not None
+ assert hotel_req.selection_criteria.start == "2024-12-01"
+
+ def test_parse_read_request_no_date(self, read_request_xml_no_date_filter):
+ """Test parsing of OTA_ReadRQ without date filter."""
+ parser = XmlParser()
+ read_request = parser.from_string(read_request_xml_no_date_filter, OtaReadRq)
+
+ assert read_request is not None
+ hotel_req = read_request.read_requests.hotel_read_request
+ assert hotel_req.hotel_code == "HOTEL123"
+ assert hotel_req.selection_criteria is None
+
+ def test_serialize_retrieve_response(
+ self,
+ sample_reservation,
+ sample_customer,
+ ):
+ """Test serialization of retrieve response to XML."""
+ reservation_pairs = [(sample_reservation, sample_customer)]
+ response = create_res_retrieve_response(reservation_pairs)
+
+ config = SerializerConfig(
+ pretty_print=True, xml_declaration=True, encoding="UTF-8"
+ )
+ serializer = XmlSerializer(config=config)
+ xml_output = serializer.render(
+ response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
+ )
+
+ # Verify it's valid XML
+ assert xml_output.startswith(b'')
+ assert b"OTA_ResRetrieveRS" in xml_output
+
+ # Verify customer data is present
+ assert b"John" in xml_output
+ assert b"Doe" in xml_output
+ assert b"john.doe@example.com" in xml_output
+
+ # Verify reservation data is present
+ assert b"RES-2024-001" in xml_output
+ assert b"HOTEL123" in xml_output
+
+
+class TestEdgeCases:
+ """Test edge cases and error conditions."""
+
+ def test_customer_with_special_characters(self):
+ """Test customer with special characters in name."""
+ customer = Customer(
+ id=99,
+ given_name="François",
+ surname="O'Brien-Smith",
+ contact_id="CONTACT-SPECIAL",
+ )
+ reservation = Reservation(
+ id=99,
+ customer_id=99,
+ unique_id="RES-SPECIAL",
+ start_date=date(2025, 1, 1),
+ end_date=date(2025, 1, 5),
+ num_adults=1,
+ num_children=0,
+ children_ages="",
+ hotel_code="HOTEL123",
+ created_at=datetime.now(UTC),
+ )
+
+ reservation_pairs = [(reservation, customer)]
+ response = create_res_retrieve_response(reservation_pairs)
+
+ config = SerializerConfig(pretty_print=True, encoding="UTF-8")
+ serializer = XmlSerializer(config=config)
+ xml_output = serializer.render(
+ response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
+ )
+
+ assert response is not None
+ assert xml_output is not None
+
+ def test_long_unique_id_truncation(self):
+ """Test that long unique IDs are handled properly."""
+ customer = Customer(
+ id=98,
+ given_name="Test",
+ surname="User",
+ contact_id="CONTACT-98",
+ )
+ # Unique ID at max length (35 chars)
+ reservation = Reservation(
+ id=98,
+ customer_id=98,
+ unique_id="A" * 35, # Max length
+ start_date=date(2025, 1, 1),
+ end_date=date(2025, 1, 5),
+ num_adults=1,
+ num_children=0,
+ children_ages="",
+ hotel_code="HOTEL123",
+ created_at=datetime.now(UTC),
+ )
+
+ reservation_pairs = [(reservation, customer)]
+ response = create_res_retrieve_response(reservation_pairs)
+
+ assert response is not None
+
+ def test_reservation_with_all_utm_parameters(self):
+ """Test reservation with all UTM tracking parameters."""
+ customer = Customer(
+ id=97,
+ given_name="Marketing",
+ surname="Test",
+ contact_id="CONTACT-97",
+ )
+ reservation = Reservation(
+ id=97,
+ customer_id=97,
+ unique_id="RES-UTM-TEST",
+ start_date=date(2025, 2, 1),
+ end_date=date(2025, 2, 7),
+ num_adults=2,
+ num_children=0,
+ children_ages="",
+ hotel_code="HOTEL123",
+ created_at=datetime.now(UTC),
+ utm_source="facebook",
+ utm_medium="social",
+ utm_campaign="spring2025",
+ utm_term="luxury resort",
+ utm_content="carousel_ad",
+ fbclid="IwAR1234567890",
+ gclid="",
+ )
+
+ reservation_pairs = [(reservation, customer)]
+ response = create_res_retrieve_response(reservation_pairs)
+
+ config = SerializerConfig(pretty_print=True)
+ serializer = XmlSerializer(config=config)
+ xml_output = serializer.render(
+ response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
+ )
+
+ assert response is not None
+ # UTM parameters should be in comments or other fields
+ assert b"RES-UTM-TEST" in xml_output
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])