From c0e601e308e7a7eea2666de827f5b89f29d4d03d Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Tue, 2 Dec 2025 15:24:30 +0100 Subject: [PATCH] Fixed up the damm tests --- src/alpine_bits_python/conversion_service.py | 24 ++-- src/alpine_bits_python/schemas.py | 131 +++++++++++++------ tests/helpers/xml_builders.py | 50 ++++++- tests/test_conversion_service.py | 58 ++++---- 4 files changed, 179 insertions(+), 84 deletions(-) diff --git a/src/alpine_bits_python/conversion_service.py b/src/alpine_bits_python/conversion_service.py index 4a6ba40..3688f72 100644 --- a/src/alpine_bits_python/conversion_service.py +++ b/src/alpine_bits_python/conversion_service.py @@ -21,7 +21,7 @@ from .db import ( SessionMaker, ) from .logging_config import get_logger -from .schemas import ConversionGuestData +from .schemas import ConversionData, ConversionGuestData _LOGGER = get_logger(__name__) @@ -552,7 +552,7 @@ class ConversionService: pms_reservation_id if successfully created/updated, None if error occurred """ - pms_reservation_id = reservation_elem.get("id") + pms_reservation_id = int(reservation_elem.get("id")) async with semaphore: # In concurrent mode, create a new session for this task @@ -652,9 +652,15 @@ class ConversionService: "daily_sales_count": 0, } - # Extract reservation metadata hotel_id = reservation_elem.get("hotelID") - pms_reservation_id = reservation_elem.get("id") + try: + # Extract reservation metadata + + pms_reservation_id = int(reservation_elem.get("id")) + except ValueError as e: + _LOGGER.error("Invalid reservation metadata in reservation element: %s", e) + return stats + reservation_number = reservation_elem.get("number") reservation_date_str = reservation_elem.get("date") creation_time_str = reservation_elem.get("creationTime") @@ -751,11 +757,8 @@ class ConversionService: else: # Create new conversion entry (without matching - will be done later) # Note: Guest information (first_name, last_name, email, etc) is stored in ConversionGuest table - conversion = Conversion( + conversion_data = ConversionData( # Links to existing entities (nullable, will be filled in after matching) - reservation_id=None, - customer_id=None, - hashed_customer_id=None, # Reservation metadata hotel_id=hotel_id, guest_id=guest_id, # Links to ConversionGuest @@ -770,9 +773,8 @@ class ConversionService: advertising_partner=advertising_partner, advertising_campagne=advertising_campagne, # Metadata - created_at=datetime.now(), - updated_at=datetime.now(), ) + conversion = Conversion(**conversion_data.model_dump()) session.add(conversion) _LOGGER.debug( "Created conversion (pms_id=%s)", @@ -1503,7 +1505,7 @@ class ConversionService: async def _match_conversion_from_db_safe( self, - pms_reservation_id: str, + pms_reservation_id: int, semaphore: asyncio.Semaphore, stats: dict[str, int], ) -> None: diff --git a/src/alpine_bits_python/schemas.py b/src/alpine_bits_python/schemas.py index 039c23b..852cf84 100644 --- a/src/alpine_bits_python/schemas.py +++ b/src/alpine_bits_python/schemas.py @@ -11,7 +11,7 @@ from XML generation (xsdata) follows clean architecture principles. import hashlib import json -from datetime import date, datetime +from datetime import UTC, date, datetime from enum import Enum from typing import Any @@ -20,6 +20,35 @@ from pydantic import BaseModel, EmailStr, Field, field_validator, model_validato from .const import WebhookStatus +# Generalized integer validator for reuse across models +def convert_to_int(field_name: str, v: Any) -> int: + """Convert a value to integer, handling string inputs. + + Args: + field_name: Name of the field being validated (for error messages) + v: Value to convert (can be int, str, or None) + + Returns: + Integer value + + Raises: + ValueError: If value is None or cannot be converted to int + + """ + if v is None: + msg = f"{field_name} cannot be None" + raise ValueError(msg) + if isinstance(v, int): + return v + if isinstance(v, str): + try: + return int(v) + except ValueError as e: + msg = f"{field_name} must be a valid integer, got: {v}" + raise ValueError(msg) from e + msg = f"{field_name} must be int or str, got: {type(v)}" + raise ValueError(msg) + # Country name to ISO 3166-1 alpha-2 code mapping COUNTRY_NAME_TO_CODE = { # English names @@ -195,6 +224,7 @@ class CustomerData(BaseModel): Returns: 2-letter ISO country code (uppercase) or None if input is None/empty + """ if not v: return None @@ -367,8 +397,7 @@ class WebhookRequestData(BaseModel): # Required fields payload_json: dict[str, Any] | None = Field( - ..., - description="Webhook payload (required for creation, nullable after purge)" + ..., description="Webhook payload (required for creation, nullable after purge)" ) # Auto-calculated from payload_json @@ -376,7 +405,7 @@ class WebhookRequestData(BaseModel): None, min_length=64, max_length=64, - description="SHA256 hash of canonical JSON payload (auto-calculated)" + description="SHA256 hash of canonical JSON payload (auto-calculated)", ) # Optional foreign keys @@ -517,49 +546,71 @@ class ConversionGuestData(BaseModel): @classmethod def convert_guest_id_to_int(cls, v: Any) -> int: """Convert guest_id to integer (handles string input from XML).""" - if v is None: - raise ValueError("guest_id cannot be None") - if isinstance(v, int): - return v - if isinstance(v, str): - try: - return int(v) - except ValueError as e: - raise ValueError(f"guest_id must be a valid integer, got: {v}") from e - raise ValueError(f"guest_id must be int or str, got: {type(v)}") + return convert_to_int("guest_id", v) model_config = {"from_attributes": True} -class ReservationService: - """Example service showing how to use Pydantic models with SQLAlchemy.""" +class ConversionData(BaseModel): + """Validated conversion data from PMS XML. - def __init__(self, db_session): - self.db_session = db_session + Handles validation for conversion records extracted from + hotel PMS conversion XML files. This model ensures proper type conversion + and validation before creating a Conversion database entry. + """ - async def create_reservation( - self, reservation_data: ReservationData, customer_data: CustomerData - ): - """Create a reservation with validated data. + # Foreign key references (nullable - matched after creation) + reservation_id: int | None = Field(None, gt=0) + customer_id: int | None = Field(None, gt=0) + hashed_customer_id: int | None = Field(None, gt=0) - The data has already been validated by Pydantic before reaching here. - """ - from alpine_bits_python.db import Customer, Reservation + # Required reservation metadata from PMS + hotel_id: str = Field(..., min_length=1, max_length=50) + pms_reservation_id: int = Field(..., gt=0) + guest_id: int | None = Field(None, gt=0) - # Convert validated Pydantic model to SQLAlchemy model - db_customer = Customer(**customer_data.model_dump(exclude_none=True)) - self.db_session.add(db_customer) - await self.db_session.flush() # Get the customer ID + # Optional reservation metadata + reservation_number: str | None = Field(None, max_length=100) + reservation_date: date | None = None + creation_time: datetime | None = None + reservation_type: str | None = Field(None, max_length=50) + booking_channel: str | None = Field(None, max_length=100) - # Create reservation linked to customer - db_reservation = Reservation( - customer_id=db_customer.id, - **reservation_data.model_dump( - exclude={"children_ages"} - ), # Handle separately - children_ages=",".join(map(str, reservation_data.children_ages)), - ) - self.db_session.add(db_reservation) - await self.db_session.commit() + # Advertising/tracking data (used for matching) + advertising_medium: str | None = Field(None, max_length=200) + advertising_partner: str | None = Field(None, max_length=200) + advertising_campagne: str | None = Field(None, max_length=500) - return db_reservation, db_customer + # Attribution flags + directly_attributable: bool = Field(default=False) + guest_matched: bool = Field(default=False) + + # Timestamps (auto-managed) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + + @field_validator( + "pms_reservation_id", "guest_id", "reservation_id", "customer_id", + "hashed_customer_id", mode="before" + ) + @classmethod + def convert_int_fields(cls, v: Any) -> int | None: + """Convert integer fields from string to int (handles XML input).""" + if v is None or v == "": + return None + # Get the field name from the validation context if available + # For now, use a generic name since we handle multiple fields + return convert_to_int("field", v) + + @field_validator("hotel_id", "reservation_number", "reservation_type", + "booking_channel", "advertising_medium", "advertising_partner", + "advertising_campagne", mode="before") + @classmethod + def strip_string_fields(cls, v: str | None) -> str | None: + """Strip whitespace from string fields.""" + if v is None: + return None + stripped = str(v).strip() + return stripped if stripped else None + + model_config = {"from_attributes": True} diff --git a/tests/helpers/xml_builders.py b/tests/helpers/xml_builders.py index 0bf622c..c8482fe 100644 --- a/tests/helpers/xml_builders.py +++ b/tests/helpers/xml_builders.py @@ -9,6 +9,43 @@ from typing import Optional from xml.etree import ElementTree as ET +def validate_and_convert_id(field_name: str, value: str | int) -> str: + """Validate that an ID field is convertible to integer and return as string. + + This helper ensures ID fields (like reservation_id, guest_id) are valid integers, + which is important since the Pydantic models will convert them from strings to ints. + + Args: + field_name: Name of the field for error messages + value: The ID value (can be string or int) + + Returns: + String representation of the validated integer ID + + Raises: + ValueError: If value cannot be converted to a valid positive integer + + """ + def _raise_invalid_type_error(): + """Raise error for invalid ID type.""" + msg = ( + f"{field_name} must be convertible to a positive integer, " + f"got: {value!r} (type: {type(value).__name__})" + ) + raise ValueError(msg) + + try: + # Convert to int first to validate it's a valid integer + int_value = int(value) + if int_value <= 0: + msg = f"{field_name} must be a positive integer, got: {value}" + raise ValueError(msg) + # Return as string for XML attributes + return str(int_value) + except (ValueError, TypeError): + _raise_invalid_type_error() + + class RoomReservationBuilder: """Builder for creating roomReservation XML elements with daily sales.""" @@ -133,7 +170,7 @@ class ReservationXMLBuilder: def __init__( self, hotel_id: str, - reservation_id: str, + reservation_id: str | int, reservation_number: str, reservation_date: str, creation_time: Optional[str] = None, @@ -146,7 +183,7 @@ class ReservationXMLBuilder: Args: hotel_id: Hotel ID - reservation_id: Reservation ID + reservation_id: Reservation ID (must be convertible to positive integer) reservation_number: Reservation number reservation_date: Reservation date in YYYY-MM-DD format creation_time: Creation timestamp (defaults to reservation_date + T00:00:00) @@ -156,7 +193,7 @@ class ReservationXMLBuilder: advertising_campagne: Advertising campaign """ self.hotel_id = hotel_id - self.reservation_id = reservation_id + self.reservation_id = validate_and_convert_id("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" @@ -170,7 +207,7 @@ class ReservationXMLBuilder: def set_guest( self, - guest_id: str, + guest_id: str | int, first_name: str, last_name: str, email: str, @@ -182,7 +219,7 @@ class ReservationXMLBuilder: """Set guest information for the reservation. Args: - guest_id: Guest ID + guest_id: Guest ID (must be convertible to positive integer) first_name: Guest first name last_name: Guest last name email: Guest email @@ -194,8 +231,9 @@ class ReservationXMLBuilder: Returns: Self for method chaining """ + validated_guest_id = validate_and_convert_id("guest_id", guest_id) self.guest_data = { - "id": guest_id, + "id": validated_guest_id, "firstName": first_name, "lastName": last_name, "email": email, diff --git a/tests/test_conversion_service.py b/tests/test_conversion_service.py index 100393f..9aa1ec2 100644 --- a/tests/test_conversion_service.py +++ b/tests/test_conversion_service.py @@ -372,13 +372,13 @@ class TestConversionServiceWithImportedData: res1_v1 = ( ReservationXMLBuilder( hotel_id="39054_001", - reservation_id="res_001", - reservation_number="RES-001", + reservation_id="100", + reservation_number="100", reservation_date="2025-11-14", reservation_type="request", ) .set_guest( - guest_id="guest_001", + guest_id="100", first_name="Alice", last_name="Johnson", email="alice@example.com", @@ -397,13 +397,13 @@ class TestConversionServiceWithImportedData: res2_v1 = ( ReservationXMLBuilder( hotel_id="39054_001", - reservation_id="res_002", - reservation_number="RES-002", + reservation_id="101", + reservation_number="101", reservation_date="2025-11-15", reservation_type="reservation", ) .set_guest( - guest_id="guest_002", + guest_id="101", first_name="Bob", last_name="Smith", email="bob@example.com", @@ -446,13 +446,13 @@ class TestConversionServiceWithImportedData: res1_v2 = ( ReservationXMLBuilder( hotel_id="39054_001", - reservation_id="res_001", # Same ID - reservation_number="RES-001", # Same number + reservation_id="100", # Same ID + reservation_number="100", # Same number reservation_date="2025-11-14", reservation_type="reservation", # Changed from request ) .set_guest( - guest_id="guest_001", + guest_id="100", first_name="Alice", last_name="Johnson", email="alice@example.com", @@ -471,13 +471,13 @@ class TestConversionServiceWithImportedData: res2_v2 = ( ReservationXMLBuilder( hotel_id="39054_001", - reservation_id="res_002", # Same ID - reservation_number="RES-002", # Same number + reservation_id="101", # Same ID + reservation_number="101", # Same number reservation_date="2025-11-15", reservation_type="request", # Changed from reservation ) .set_guest( - guest_id="guest_002", + guest_id="101", first_name="Bob", last_name="Smith", email="bob@example.com", @@ -554,12 +554,12 @@ class TestXMLBuilderUsage: xml_content = ( ReservationXMLBuilder( hotel_id="39054_001", - reservation_id="test_123", - reservation_number="RES-123", + reservation_id="123", + reservation_number="123", reservation_date="2025-11-14", ) .set_guest( - guest_id="guest_001", + guest_id="157", first_name="John", last_name="Doe", email="john@example.com", @@ -591,12 +591,12 @@ class TestXMLBuilderUsage: xml_content = ( ReservationXMLBuilder( hotel_id="39054_001", - reservation_id="test_456", - reservation_number="RES-456", + reservation_id="456", + reservation_number="456", reservation_date="2025-11-14", ) .set_guest( - guest_id="guest_002", + guest_id="157", first_name="Jane", last_name="Smith", email="jane@example.com", @@ -634,12 +634,12 @@ class TestXMLBuilderUsage: res1 = ( ReservationXMLBuilder( hotel_id="39054_001", - reservation_id="test_001", - reservation_number="RES-001", + reservation_id="175", + reservation_number="175", reservation_date="2025-11-14", ) .set_guest( - guest_id="guest_001", + guest_id="157", first_name="Alice", last_name="Johnson", email="alice@example.com", @@ -656,12 +656,12 @@ class TestXMLBuilderUsage: res2 = ( ReservationXMLBuilder( hotel_id="39054_001", - reservation_id="test_002", + reservation_id="2725", reservation_number="RES-002", reservation_date="2025-11-15", ) .set_guest( - guest_id="guest_002", + guest_id="2525", first_name="Bob", last_name="Williams", email="bob@example.com", @@ -752,10 +752,12 @@ class TestHashedMatchingLogic: test_db_session.add(reservation) await test_db_session.commit() + PMS_RESERVATION_ID = 157 + # Create conversion XML with matching hashed data - xml_content = """ + xml_content = f""" - + @@ -772,7 +774,9 @@ class TestHashedMatchingLogic: # Verify conversion was created result = await test_db_session.execute( - select(Conversion).where(Conversion.pms_reservation_id == "pms_123") + select(Conversion).where( + Conversion.pms_reservation_id == PMS_RESERVATION_ID + ) ) conversion = result.scalar_one_or_none() @@ -785,7 +789,7 @@ class TestHashedMatchingLogic: result_with_guest = await test_db_session.execute( select(Conversion) - .where(Conversion.pms_reservation_id == "pms_123") + .where(Conversion.pms_reservation_id == PMS_RESERVATION_ID) .options(selectinload(Conversion.guest)) ) conversion_with_guest = result_with_guest.scalar_one_or_none()