Fixed up the damm tests

This commit is contained in:
Jonas Linter
2025-12-02 15:24:30 +01:00
parent 56d67984cf
commit c0e601e308
4 changed files with 179 additions and 84 deletions

View File

@@ -21,7 +21,7 @@ from .db import (
SessionMaker, SessionMaker,
) )
from .logging_config import get_logger from .logging_config import get_logger
from .schemas import ConversionGuestData from .schemas import ConversionData, ConversionGuestData
_LOGGER = get_logger(__name__) _LOGGER = get_logger(__name__)
@@ -552,7 +552,7 @@ class ConversionService:
pms_reservation_id if successfully created/updated, None if error occurred 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: async with semaphore:
# In concurrent mode, create a new session for this task # In concurrent mode, create a new session for this task
@@ -652,9 +652,15 @@ class ConversionService:
"daily_sales_count": 0, "daily_sales_count": 0,
} }
# Extract reservation metadata
hotel_id = reservation_elem.get("hotelID") 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_number = reservation_elem.get("number")
reservation_date_str = reservation_elem.get("date") reservation_date_str = reservation_elem.get("date")
creation_time_str = reservation_elem.get("creationTime") creation_time_str = reservation_elem.get("creationTime")
@@ -751,11 +757,8 @@ class ConversionService:
else: else:
# Create new conversion entry (without matching - will be done later) # Create new conversion entry (without matching - will be done later)
# Note: Guest information (first_name, last_name, email, etc) is stored in ConversionGuest table # 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) # Links to existing entities (nullable, will be filled in after matching)
reservation_id=None,
customer_id=None,
hashed_customer_id=None,
# Reservation metadata # Reservation metadata
hotel_id=hotel_id, hotel_id=hotel_id,
guest_id=guest_id, # Links to ConversionGuest guest_id=guest_id, # Links to ConversionGuest
@@ -770,9 +773,8 @@ class ConversionService:
advertising_partner=advertising_partner, advertising_partner=advertising_partner,
advertising_campagne=advertising_campagne, advertising_campagne=advertising_campagne,
# Metadata # Metadata
created_at=datetime.now(),
updated_at=datetime.now(),
) )
conversion = Conversion(**conversion_data.model_dump())
session.add(conversion) session.add(conversion)
_LOGGER.debug( _LOGGER.debug(
"Created conversion (pms_id=%s)", "Created conversion (pms_id=%s)",
@@ -1503,7 +1505,7 @@ class ConversionService:
async def _match_conversion_from_db_safe( async def _match_conversion_from_db_safe(
self, self,
pms_reservation_id: str, pms_reservation_id: int,
semaphore: asyncio.Semaphore, semaphore: asyncio.Semaphore,
stats: dict[str, int], stats: dict[str, int],
) -> None: ) -> None:

View File

@@ -11,7 +11,7 @@ from XML generation (xsdata) follows clean architecture principles.
import hashlib import hashlib
import json import json
from datetime import date, datetime from datetime import UTC, date, datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any
@@ -20,6 +20,35 @@ from pydantic import BaseModel, EmailStr, Field, field_validator, model_validato
from .const import WebhookStatus 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 ISO 3166-1 alpha-2 code mapping
COUNTRY_NAME_TO_CODE = { COUNTRY_NAME_TO_CODE = {
# English names # English names
@@ -195,6 +224,7 @@ class CustomerData(BaseModel):
Returns: Returns:
2-letter ISO country code (uppercase) or None if input is None/empty 2-letter ISO country code (uppercase) or None if input is None/empty
""" """
if not v: if not v:
return None return None
@@ -367,8 +397,7 @@ class WebhookRequestData(BaseModel):
# Required fields # Required fields
payload_json: dict[str, Any] | None = Field( 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 # Auto-calculated from payload_json
@@ -376,7 +405,7 @@ class WebhookRequestData(BaseModel):
None, None,
min_length=64, min_length=64,
max_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 # Optional foreign keys
@@ -517,49 +546,71 @@ class ConversionGuestData(BaseModel):
@classmethod @classmethod
def convert_guest_id_to_int(cls, v: Any) -> int: def convert_guest_id_to_int(cls, v: Any) -> int:
"""Convert guest_id to integer (handles string input from XML).""" """Convert guest_id to integer (handles string input from XML)."""
if v is None: return convert_to_int("guest_id", v)
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)}")
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class ReservationService: class ConversionData(BaseModel):
"""Example service showing how to use Pydantic models with SQLAlchemy.""" """Validated conversion data from PMS XML.
def __init__(self, db_session): Handles validation for conversion records extracted from
self.db_session = db_session hotel PMS conversion XML files. This model ensures proper type conversion
and validation before creating a Conversion database entry.
"""
async def create_reservation( # Foreign key references (nullable - matched after creation)
self, reservation_data: ReservationData, customer_data: CustomerData reservation_id: int | None = Field(None, gt=0)
): customer_id: int | None = Field(None, gt=0)
"""Create a reservation with validated data. hashed_customer_id: int | None = Field(None, gt=0)
The data has already been validated by Pydantic before reaching here. # Required reservation metadata from PMS
""" hotel_id: str = Field(..., min_length=1, max_length=50)
from alpine_bits_python.db import Customer, Reservation pms_reservation_id: int = Field(..., gt=0)
guest_id: int | None = Field(None, gt=0)
# Convert validated Pydantic model to SQLAlchemy model # Optional reservation metadata
db_customer = Customer(**customer_data.model_dump(exclude_none=True)) reservation_number: str | None = Field(None, max_length=100)
self.db_session.add(db_customer) reservation_date: date | None = None
await self.db_session.flush() # Get the customer ID 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 # Advertising/tracking data (used for matching)
db_reservation = Reservation( advertising_medium: str | None = Field(None, max_length=200)
customer_id=db_customer.id, advertising_partner: str | None = Field(None, max_length=200)
**reservation_data.model_dump( advertising_campagne: str | None = Field(None, max_length=500)
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()
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}

View File

@@ -9,6 +9,43 @@ from typing import Optional
from xml.etree import ElementTree as ET 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: class RoomReservationBuilder:
"""Builder for creating roomReservation XML elements with daily sales.""" """Builder for creating roomReservation XML elements with daily sales."""
@@ -133,7 +170,7 @@ class ReservationXMLBuilder:
def __init__( def __init__(
self, self,
hotel_id: str, hotel_id: str,
reservation_id: str, reservation_id: str | int,
reservation_number: str, reservation_number: str,
reservation_date: str, reservation_date: str,
creation_time: Optional[str] = None, creation_time: Optional[str] = None,
@@ -146,7 +183,7 @@ class ReservationXMLBuilder:
Args: Args:
hotel_id: Hotel ID hotel_id: Hotel ID
reservation_id: Reservation ID reservation_id: Reservation ID (must be convertible to positive integer)
reservation_number: Reservation number reservation_number: Reservation number
reservation_date: Reservation date in YYYY-MM-DD format reservation_date: Reservation date in YYYY-MM-DD format
creation_time: Creation timestamp (defaults to reservation_date + T00:00:00) creation_time: Creation timestamp (defaults to reservation_date + T00:00:00)
@@ -156,7 +193,7 @@ class ReservationXMLBuilder:
advertising_campagne: Advertising campaign advertising_campagne: Advertising campaign
""" """
self.hotel_id = hotel_id 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_number = reservation_number
self.reservation_date = reservation_date self.reservation_date = reservation_date
self.creation_time = creation_time or f"{reservation_date}T00:00:00" self.creation_time = creation_time or f"{reservation_date}T00:00:00"
@@ -170,7 +207,7 @@ class ReservationXMLBuilder:
def set_guest( def set_guest(
self, self,
guest_id: str, guest_id: str | int,
first_name: str, first_name: str,
last_name: str, last_name: str,
email: str, email: str,
@@ -182,7 +219,7 @@ class ReservationXMLBuilder:
"""Set guest information for the reservation. """Set guest information for the reservation.
Args: Args:
guest_id: Guest ID guest_id: Guest ID (must be convertible to positive integer)
first_name: Guest first name first_name: Guest first name
last_name: Guest last name last_name: Guest last name
email: Guest email email: Guest email
@@ -194,8 +231,9 @@ class ReservationXMLBuilder:
Returns: Returns:
Self for method chaining Self for method chaining
""" """
validated_guest_id = validate_and_convert_id("guest_id", guest_id)
self.guest_data = { self.guest_data = {
"id": guest_id, "id": validated_guest_id,
"firstName": first_name, "firstName": first_name,
"lastName": last_name, "lastName": last_name,
"email": email, "email": email,

View File

@@ -372,13 +372,13 @@ class TestConversionServiceWithImportedData:
res1_v1 = ( res1_v1 = (
ReservationXMLBuilder( ReservationXMLBuilder(
hotel_id="39054_001", hotel_id="39054_001",
reservation_id="res_001", reservation_id="100",
reservation_number="RES-001", reservation_number="100",
reservation_date="2025-11-14", reservation_date="2025-11-14",
reservation_type="request", reservation_type="request",
) )
.set_guest( .set_guest(
guest_id="guest_001", guest_id="100",
first_name="Alice", first_name="Alice",
last_name="Johnson", last_name="Johnson",
email="alice@example.com", email="alice@example.com",
@@ -397,13 +397,13 @@ class TestConversionServiceWithImportedData:
res2_v1 = ( res2_v1 = (
ReservationXMLBuilder( ReservationXMLBuilder(
hotel_id="39054_001", hotel_id="39054_001",
reservation_id="res_002", reservation_id="101",
reservation_number="RES-002", reservation_number="101",
reservation_date="2025-11-15", reservation_date="2025-11-15",
reservation_type="reservation", reservation_type="reservation",
) )
.set_guest( .set_guest(
guest_id="guest_002", guest_id="101",
first_name="Bob", first_name="Bob",
last_name="Smith", last_name="Smith",
email="bob@example.com", email="bob@example.com",
@@ -446,13 +446,13 @@ class TestConversionServiceWithImportedData:
res1_v2 = ( res1_v2 = (
ReservationXMLBuilder( ReservationXMLBuilder(
hotel_id="39054_001", hotel_id="39054_001",
reservation_id="res_001", # Same ID reservation_id="100", # Same ID
reservation_number="RES-001", # Same number reservation_number="100", # Same number
reservation_date="2025-11-14", reservation_date="2025-11-14",
reservation_type="reservation", # Changed from request reservation_type="reservation", # Changed from request
) )
.set_guest( .set_guest(
guest_id="guest_001", guest_id="100",
first_name="Alice", first_name="Alice",
last_name="Johnson", last_name="Johnson",
email="alice@example.com", email="alice@example.com",
@@ -471,13 +471,13 @@ class TestConversionServiceWithImportedData:
res2_v2 = ( res2_v2 = (
ReservationXMLBuilder( ReservationXMLBuilder(
hotel_id="39054_001", hotel_id="39054_001",
reservation_id="res_002", # Same ID reservation_id="101", # Same ID
reservation_number="RES-002", # Same number reservation_number="101", # Same number
reservation_date="2025-11-15", reservation_date="2025-11-15",
reservation_type="request", # Changed from reservation reservation_type="request", # Changed from reservation
) )
.set_guest( .set_guest(
guest_id="guest_002", guest_id="101",
first_name="Bob", first_name="Bob",
last_name="Smith", last_name="Smith",
email="bob@example.com", email="bob@example.com",
@@ -554,12 +554,12 @@ class TestXMLBuilderUsage:
xml_content = ( xml_content = (
ReservationXMLBuilder( ReservationXMLBuilder(
hotel_id="39054_001", hotel_id="39054_001",
reservation_id="test_123", reservation_id="123",
reservation_number="RES-123", reservation_number="123",
reservation_date="2025-11-14", reservation_date="2025-11-14",
) )
.set_guest( .set_guest(
guest_id="guest_001", guest_id="157",
first_name="John", first_name="John",
last_name="Doe", last_name="Doe",
email="john@example.com", email="john@example.com",
@@ -591,12 +591,12 @@ class TestXMLBuilderUsage:
xml_content = ( xml_content = (
ReservationXMLBuilder( ReservationXMLBuilder(
hotel_id="39054_001", hotel_id="39054_001",
reservation_id="test_456", reservation_id="456",
reservation_number="RES-456", reservation_number="456",
reservation_date="2025-11-14", reservation_date="2025-11-14",
) )
.set_guest( .set_guest(
guest_id="guest_002", guest_id="157",
first_name="Jane", first_name="Jane",
last_name="Smith", last_name="Smith",
email="jane@example.com", email="jane@example.com",
@@ -634,12 +634,12 @@ class TestXMLBuilderUsage:
res1 = ( res1 = (
ReservationXMLBuilder( ReservationXMLBuilder(
hotel_id="39054_001", hotel_id="39054_001",
reservation_id="test_001", reservation_id="175",
reservation_number="RES-001", reservation_number="175",
reservation_date="2025-11-14", reservation_date="2025-11-14",
) )
.set_guest( .set_guest(
guest_id="guest_001", guest_id="157",
first_name="Alice", first_name="Alice",
last_name="Johnson", last_name="Johnson",
email="alice@example.com", email="alice@example.com",
@@ -656,12 +656,12 @@ class TestXMLBuilderUsage:
res2 = ( res2 = (
ReservationXMLBuilder( ReservationXMLBuilder(
hotel_id="39054_001", hotel_id="39054_001",
reservation_id="test_002", reservation_id="2725",
reservation_number="RES-002", reservation_number="RES-002",
reservation_date="2025-11-15", reservation_date="2025-11-15",
) )
.set_guest( .set_guest(
guest_id="guest_002", guest_id="2525",
first_name="Bob", first_name="Bob",
last_name="Williams", last_name="Williams",
email="bob@example.com", email="bob@example.com",
@@ -752,10 +752,12 @@ class TestHashedMatchingLogic:
test_db_session.add(reservation) test_db_session.add(reservation)
await test_db_session.commit() await test_db_session.commit()
PMS_RESERVATION_ID = 157
# Create conversion XML with matching hashed data # Create conversion XML with matching hashed data
xml_content = """<?xml version="1.0"?> xml_content = f"""<?xml version="1.0"?>
<root> <root>
<reservation id="pms_123" hotelID="hotel_1" number="RES001" date="2025-01-15"> <reservation id="{PMS_RESERVATION_ID}" hotelID="hotel_1" number="378" date="2025-01-15">
<guest id="123" firstName="David" lastName="Miller" email="david@example.com"/> <guest id="123" firstName="David" lastName="Miller" email="david@example.com"/>
<roomReservations> <roomReservations>
<roomReservation roomNumber="101" arrival="2025-01-15" departure="2025-01-17" status="confirmed"> <roomReservation roomNumber="101" arrival="2025-01-15" departure="2025-01-17" status="confirmed">
@@ -772,7 +774,9 @@ class TestHashedMatchingLogic:
# Verify conversion was created # Verify conversion was created
result = await test_db_session.execute( 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() conversion = result.scalar_one_or_none()
@@ -785,7 +789,7 @@ class TestHashedMatchingLogic:
result_with_guest = await test_db_session.execute( result_with_guest = await test_db_session.execute(
select(Conversion) select(Conversion)
.where(Conversion.pms_reservation_id == "pms_123") .where(Conversion.pms_reservation_id == PMS_RESERVATION_ID)
.options(selectinload(Conversion.guest)) .options(selectinload(Conversion.guest))
) )
conversion_with_guest = result_with_guest.scalar_one_or_none() conversion_with_guest = result_with_guest.scalar_one_or_none()