Files
alpinebits_python/tests/test_conversion_service.py

1194 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for ConversionService using realistic test data.
This test module:
1. Uses the CSV import tests to populate the in-memory database with realistic customer/reservation data
2. Runs the XML conversion import endpoint with conversions_test_data.xml
3. Asserts baseline match counts to detect regressions in matching logic
The test data is designed to test realistic matching scenarios:
- Matching by advertising campaign data (fbclid/gclid)
- Matching by guest name and email using hashed data
- Handling unmatched conversions
- Processing daily sales revenue data
- Testing hashed matching logic and edge cases
"""
import hashlib
from pathlib import Path
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from alpine_bits_python.conversion_service import ConversionService
from alpine_bits_python.csv_import import CSVImporter
from alpine_bits_python.db import (
Base,
Conversion,
ConversionGuest,
ConversionRoom,
Customer,
Reservation,
)
@pytest_asyncio.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_asyncio.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 test_config():
"""Test configuration."""
return {
"server": {
"codecontext": "ADVERTISING",
"code": "70597314",
"companyname": "99tales Gmbh",
"res_id_source_context": "99tales",
},
"alpine_bits_auth": [
{
"hotel_id": "39054_001",
"hotel_name": "Bemelmans Apartments",
"username": "bemelmans_user",
"password": "testpass",
}
],
"default_hotel_code": "39054_001",
"default_hotel_name": "Bemelmans Apartments",
}
@pytest.fixture
def test_data_dir():
"""Return path to test data directory."""
return Path(__file__).parent / "test_data"
class TestConversionServiceWithImportedData:
"""Test ConversionService using realistic test data imported via CSV."""
@pytest.mark.asyncio
async def test_conversion_import_with_csv_test_data(
self, test_db_session, test_config, test_data_dir
):
"""Test full workflow: import CSV data, then process conversions XML.
This test demonstrates the intended workflow:
1. Import CSV test data to populate customers and reservations
2. Process conversion XML file to match conversions to reservations
3. Verify match statistics to detect regressions
The conversions_test_data.xml file contains realistic conversion data
from a hotel PMS system with multiple reservations and daily sales.
"""
csv_file = test_data_dir / "leads_export.csv"
xml_file = test_data_dir / "conversions_test_data.xml"
# Skip test if data files don't exist
if not csv_file.exists():
pytest.skip(f"Test data file not found: {csv_file}")
if not xml_file.exists():
pytest.skip(f"Test data file not found: {xml_file}")
# Step 1: Import CSV data to populate database with realistic customers/reservations
importer = CSVImporter(test_db_session, test_config)
csv_stats = await importer.import_csv_file(
csv_file_path=str(csv_file),
hotel_code="39054_001",
dryrun=False,
)
print(f"\nCSV Import Stats: {csv_stats}")
assert csv_stats["total_rows"] > 0, "CSV import should have processed rows"
assert csv_stats["created_reservations"] > 0, (
"CSV import should create reservations"
)
# Step 2: Load and process conversion XML
with xml_file.open(encoding="utf-8") as f:
xml_content = f.read()
# File already has proper XML structure, just use it as-is
xml_content = xml_content.strip()
## Need to check if reservations and customers are now actually available in the db before proceeding
conversion_service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await conversion_service.process_conversion_xml(xml_content)
# BASELINE ASSERTIONS:
# These values are established from test runs with conversions_test_data.xml + leads_export.csv.
# If these change, it indicates a change in matching logic that needs review.
# Update these values only when intentionally changing the matching behavior.
#
# Current test data contains:
# - CSV import: 576 total rows, 535 created reservations, 41 duplicates skipped
# - XML conversions: 252 reservations with 2905 daily sales records across 539 room records
EXPECTED_TOTAL_RESERVATIONS = 252
EXPECTED_TOTAL_DAILY_SALES = 2905
EXPECTED_TOTAL_ROOMS = 539
# Note: Currently no matches by tracking ID because XML data uses different formats
# This is expected with the test data. Real PMS data would have higher match rates.
# With the refactored Phase 3b/3c matching logic, we now properly link guest-matched
# conversions to reservations when dates match, so we get 19 matched to reservation
# instead of just matched to customer.
EXPECTED_MATCHED_TO_RESERVATION = 19
EXPECTED_MATCHED_TO_CUSTOMER = 0
print("\nBaseline Match Counts:")
print(f" Total reservations in XML: {EXPECTED_TOTAL_RESERVATIONS}")
print(f" Total daily sales records: {EXPECTED_TOTAL_DAILY_SALES}")
print(f" Total conversion room records: {EXPECTED_TOTAL_ROOMS}")
print(f" Matched to reservation: {EXPECTED_MATCHED_TO_RESERVATION}")
match_rate = (
(EXPECTED_MATCHED_TO_RESERVATION / EXPECTED_TOTAL_RESERVATIONS * 100)
if EXPECTED_TOTAL_RESERVATIONS > 0
else 0
)
print(f" Match rate: {match_rate:.1f}%")
print(f" Matched to customer: {EXPECTED_MATCHED_TO_CUSTOMER}")
print(
f" Match rate (to customer): {(EXPECTED_MATCHED_TO_CUSTOMER / EXPECTED_TOTAL_RESERVATIONS * 100) if EXPECTED_TOTAL_RESERVATIONS > 0 else 0:.1f}%"
)
# Verify baseline stability on subsequent runs
assert stats["total_reservations"] == EXPECTED_TOTAL_RESERVATIONS, (
f"Total reservations should be {EXPECTED_TOTAL_RESERVATIONS}, got {stats['total_reservations']}"
)
assert stats["total_daily_sales"] == EXPECTED_TOTAL_DAILY_SALES, (
f"Total daily sales should be {EXPECTED_TOTAL_DAILY_SALES}, got {stats['total_daily_sales']}"
)
assert stats["matched_to_reservation"] == EXPECTED_MATCHED_TO_RESERVATION, (
f"Matched reservations should be {EXPECTED_MATCHED_TO_RESERVATION}, got {stats['matched_to_reservation']}"
)
assert stats["matched_to_customer"] == EXPECTED_MATCHED_TO_CUSTOMER, (
f"Matched customers should be {EXPECTED_MATCHED_TO_CUSTOMER}, got {stats['matched_to_customer']}"
)
@pytest.mark.asyncio
async def test_conversion_room_revenue_aggregation(
self, test_db_session, test_config, test_data_dir
):
"""Test that daily sales revenue is correctly aggregated at room level."""
csv_file = test_data_dir / "leads_export.csv"
xml_file = test_data_dir / "conversions_test_data.xml"
if not csv_file.exists():
pytest.skip(f"Test data file not found: {csv_file}")
if not xml_file.exists():
pytest.skip(f"Test data file not found: {xml_file}")
# Import CSV data
importer = CSVImporter(test_db_session, test_config)
await importer.import_csv_file(
csv_file_path=str(csv_file),
hotel_code="39054_001",
dryrun=False,
)
# Process conversions
with xml_file.open(encoding="utf-8") as f:
xml_content = f.read()
# File already has proper XML structure, just use it as-is
xml_content = xml_content.strip()
conversion_service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await conversion_service.process_conversion_xml(xml_content)
# Verify conversions were created
from sqlalchemy import select
result = await test_db_session.execute(select(ConversionRoom))
all_rooms = result.scalars().all()
assert len(all_rooms) > 0, "Should have created conversion rooms"
# Verify there are room records even if no revenue is set
result = await test_db_session.execute(
select(ConversionRoom).where(ConversionRoom.total_revenue.isnot(None))
)
rooms_with_revenue = result.scalars().all()
# Note: Test data may not have revenue values in the XML
# The important thing is that we're capturing room-level data
print("\nRevenue Aggregation Stats:")
print(f" Total conversion rooms: {len(all_rooms)}")
print(f" Rooms with revenue: {len(rooms_with_revenue)}")
if rooms_with_revenue:
# Verify revenue values are numeric and positive
for room in rooms_with_revenue:
assert isinstance(room.total_revenue, (int, float)), (
f"Revenue should be numeric, got {type(room.total_revenue)}"
)
assert room.total_revenue > 0, (
f"Revenue should be positive, got {room.total_revenue}"
)
total_revenue = sum(room.total_revenue for room in rooms_with_revenue)
print(f" Total aggregated revenue: {total_revenue}")
print(
f" Average revenue per room: {total_revenue / len(rooms_with_revenue)}"
)
@pytest.mark.asyncio
async def test_conversion_matching_by_guest_details(
self, test_db_session, test_config, test_data_dir
):
"""Test conversion matching by guest name and email fallback.
Note: The test data may not have matching guest names/emails between
the CSV and XML files. This test primarily verifies that the matching
logic runs without errors and that the conversion service attempts to
match by guest details when advertising data is unavailable.
"""
csv_file = test_data_dir / "leads_export.csv"
xml_file = test_data_dir / "conversions_test_data.xml"
if not csv_file.exists():
pytest.skip(f"Test data file not found: {csv_file}")
if not xml_file.exists():
pytest.skip(f"Test data file not found: {xml_file}")
# Import CSV data
importer = CSVImporter(test_db_session, test_config)
csv_stats = await importer.import_csv_file(
csv_file_path=str(csv_file),
hotel_code="39054_001",
dryrun=False,
)
assert csv_stats["created_reservations"] > 0, (
"Should have imported reservations"
)
# Process conversions
with xml_file.open(encoding="utf-8") as f:
xml_content = f.read()
# File already has proper XML structure, just use it as-is
xml_content = xml_content.strip()
conversion_service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await conversion_service.process_conversion_xml(xml_content)
# Verify conversions were processed
from sqlalchemy import select
result = await test_db_session.execute(select(Conversion))
all_conversions = result.scalars().all()
assert len(all_conversions) > 0, "Should have created conversions"
# Check for matched conversions
result = await test_db_session.execute(
select(Conversion).where(Conversion.customer_id.isnot(None))
)
conversions_with_customers = result.scalars().all()
print("\nGuest Detail Matching:")
print(f" Total conversions: {len(all_conversions)}")
print(f" Conversions matched to customer: {len(conversions_with_customers)}")
print(f" Stats matched_to_customer: {stats['matched_to_customer']}")
# With this test data, matches may be 0 if guest names/emails don't align
# The important thing is that the matching logic runs without errors
print(" Note: Matches depend on data alignment between CSV and XML files")
@pytest.mark.asyncio
async def test_conversion_service_error_handling(
self, test_db_session, test_config
):
"""Test ConversionService handles invalid XML gracefully."""
invalid_xml = "<invalid>unclosed tag"
conversion_service = ConversionService(test_db_session, hotel_id="39054_001")
with pytest.raises(ValueError, match="Invalid XML"):
await conversion_service.process_conversion_xml(invalid_xml)
@pytest.mark.asyncio
async def test_conversion_service_empty_xml(self, test_db_session, test_config):
"""Test ConversionService handles empty/minimal XML."""
minimal_xml = '<?xml version="1.0"?><root></root>'
conversion_service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await conversion_service.process_conversion_xml(minimal_xml)
assert stats["total_reservations"] == 0
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 MultiReservationXMLBuilder, ReservationXMLBuilder
# 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="100",
reservation_number="100",
reservation_date="2025-11-14",
reservation_type="request",
)
.set_guest(
guest_id="100",
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="101",
reservation_number="101",
reservation_date="2025-11-15",
reservation_type="reservation",
)
.set_guest(
guest_id="101",
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, hotel_id="39054_001")
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="100", # Same ID
reservation_number="100", # Same number
reservation_date="2025-11-14",
reservation_type="reservation", # Changed from request
)
.set_guest(
guest_id="100",
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="101", # Same ID
reservation_number="101", # Same number
reservation_date="2025-11-15",
reservation_type="request", # Changed from reservation
)
.set_guest(
guest_id="101",
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 TestConversionUpdatesAndMatching:
"""Tests covering conversion updates and core matching logic."""
@pytest.mark.asyncio
async def test_reprocessing_conversion_updates_metadata(self, test_db_session):
"""Ensure reprocessing a reservation updates metadata instead of duplicating."""
def build_xml(
*,
booking_channel: str,
advertising_medium: str,
advertising_partner: str,
room_number: str,
arrival: str,
departure: str,
revenue: float,
) -> str:
return f"""<?xml version="1.0"?>
<root>
<reservation id="2001" hotelID="39054_001" number="A-1" date="2025-01-05"
bookingChannel="{booking_channel}"
advertisingMedium="{advertising_medium}"
advertisingPartner="{advertising_partner}"
advertisingCampagne="abc123">
<guest id="900" firstName="Casey" lastName="Jordan" email="casey@example.com"/>
<roomReservations>
<roomReservation roomNumber="{room_number}" arrival="{arrival}" departure="{departure}" status="reserved">
<dailySales>
<dailySale date="{arrival}" revenueTotal="{revenue}"/>
<dailySale date="{departure}" revenueTotal="{revenue}"/>
</dailySales>
</roomReservation>
</roomReservations>
</reservation>
</root>"""
first_xml = build_xml(
booking_channel="OTA",
advertising_medium="META",
advertising_partner="cpc",
room_number="33",
arrival="2025-02-01",
departure="2025-02-03",
revenue=120.0,
)
service = ConversionService(test_db_session, hotel_id="39054_001")
stats_first = await service.process_conversion_xml(first_xml)
assert stats_first["total_reservations"] == 1
result = await test_db_session.execute(
select(Conversion)
.where(
Conversion.hotel_id == "39054_001",
Conversion.pms_reservation_id == 2001,
)
.options(selectinload(Conversion.conversion_rooms))
)
conversion = result.scalar_one()
assert conversion.booking_channel == "OTA"
assert conversion.advertising_partner == "cpc"
original_room_count = len(conversion.conversion_rooms)
assert original_room_count == 1
assert conversion.conversion_rooms[0].room_number == "33"
updated_xml = build_xml(
booking_channel="DIRECT",
advertising_medium="WEBSITE",
advertising_partner="organic",
room_number="44",
arrival="2025-02-02",
departure="2025-02-04",
revenue=150.0,
)
stats_second = await service.process_conversion_xml(updated_xml)
assert stats_second["total_reservations"] == 1
test_db_session.expire_all()
result = await test_db_session.execute(
select(Conversion)
.where(
Conversion.hotel_id == "39054_001",
Conversion.pms_reservation_id == 2001,
)
.options(selectinload(Conversion.conversion_rooms))
)
updated_conversion = result.scalar_one()
assert updated_conversion.booking_channel == "DIRECT"
assert updated_conversion.advertising_medium == "WEBSITE"
assert updated_conversion.advertising_partner == "organic"
assert len(updated_conversion.conversion_rooms) == 1
assert updated_conversion.conversion_rooms[0].room_number == "44"
assert updated_conversion.conversion_rooms[0].arrival_date.strftime(
"%Y-%m-%d"
) == "2025-02-02"
@pytest.mark.asyncio
async def test_advertising_match_uses_hashed_email_for_disambiguation(
self, test_db_session
):
"""Ensure hashed email filters ambiguous advertising matches."""
# Create two customers/reservations sharing the same click-id prefix
customer_a = Customer(
given_name="Lara",
surname="North",
email_address="lara@example.com",
contact_id="contact_a",
)
customer_a.update_hashed_fields()
customer_b = Customer(
given_name="Mia",
surname="West",
email_address="mia@example.com",
contact_id="contact_b",
)
customer_b.update_hashed_fields()
test_db_session.add_all([customer_a, customer_b])
await test_db_session.flush()
reservation_a = Reservation(
customer_id=customer_a.id,
unique_id="res_a",
md5_unique_id="A" * 32,
hotel_id="39054_001",
fbclid="click-prefix-111",
)
reservation_b = Reservation(
customer_id=customer_b.id,
unique_id="res_b",
md5_unique_id="B" * 32,
hotel_id="39054_001",
fbclid="click-prefix-222",
)
test_db_session.add_all([reservation_a, reservation_b])
await test_db_session.commit()
from tests.helpers import ReservationXMLBuilder
xml_content = (
ReservationXMLBuilder(
hotel_id="39054_001",
reservation_id="3001",
reservation_number="B-1",
reservation_date="2025-03-10",
advertising_campagne="click-prefix",
)
.set_guest(
guest_id="701",
first_name="Mia",
last_name="West",
email="mia@example.com",
)
.add_room(
arrival="2025-04-01",
departure="2025-04-03",
room_number="55",
status="reserved",
revenue_logis_per_day=180.0,
)
.build_xml()
)
service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await service.process_conversion_xml(xml_content)
result = await test_db_session.execute(
select(Conversion)
.where(
Conversion.hotel_id == "39054_001",
Conversion.pms_reservation_id == 3001,
)
.options(selectinload(Conversion.guest))
)
conversion = result.scalar_one()
assert conversion.reservation_id == reservation_b.id
assert conversion.customer_id == customer_b.id
assert stats["matched_to_reservation"] == 1
assert stats["matched_to_customer"] == 0
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="123",
reservation_number="123",
reservation_date="2025-11-14",
)
.set_guest(
guest_id="157",
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, hotel_id="39054_001")
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="456",
reservation_number="456",
reservation_date="2025-11-14",
)
.set_guest(
guest_id="157",
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, hotel_id="39054_001")
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 MultiReservationXMLBuilder, ReservationXMLBuilder
multi_builder = MultiReservationXMLBuilder()
# Add first reservation
res1 = (
ReservationXMLBuilder(
hotel_id="39054_001",
reservation_id="175",
reservation_number="175",
reservation_date="2025-11-14",
)
.set_guest(
guest_id="157",
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="2725",
reservation_number="RES-002",
reservation_date="2025-11-15",
)
.set_guest(
guest_id="2525",
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, hotel_id="39054_001")
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):
"""Test that ConversionGuest properly stores hashed versions of guest data."""
# Create a conversion guest
conversion_guest = ConversionGuest.create_from_conversion_data(
hotel_id="test_hotel",
guest_id=123,
guest_first_name="Margaret",
guest_last_name="Brown",
guest_email="margaret@example.com",
guest_country_code="GB",
guest_birth_date=None,
now=None,
)
test_db_session.add(conversion_guest)
await test_db_session.flush()
# Verify hashed fields are populated
assert conversion_guest.hashed_first_name is not None
assert conversion_guest.hashed_last_name is not None
assert conversion_guest.hashed_email is not None
# Verify hashes are correct (SHA256)
expected_hashed_first = hashlib.sha256(
"margaret".lower().strip().encode("utf-8")
).hexdigest()
expected_hashed_last = hashlib.sha256(
"brown".lower().strip().encode("utf-8")
).hexdigest()
expected_hashed_email = hashlib.sha256(
"margaret@example.com".lower().strip().encode("utf-8")
).hexdigest()
assert conversion_guest.hashed_first_name == expected_hashed_first
assert conversion_guest.hashed_last_name == expected_hashed_last
assert conversion_guest.hashed_email == expected_hashed_email
@pytest.mark.asyncio
async def test_conversion_records_created_before_matching(
self, test_db_session, test_config
):
"""Test that conversion records exist before matching occurs."""
# Create customer and reservation for matching
customer = Customer(
given_name="David",
surname="Miller",
email_address="david@example.com",
contact_id="test_contact_6",
)
test_db_session.add(customer)
await test_db_session.flush()
customer.update_hashed_fields()
reservation = Reservation(
customer_id=customer.id,
unique_id="res_6",
hotel_id="hotel_1",
)
test_db_session.add(reservation)
await test_db_session.commit()
PMS_RESERVATION_ID = 157
# Create conversion XML with matching hashed data
xml_content = f"""<?xml version="1.0"?>
<root>
<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"/>
<roomReservations>
<roomReservation roomNumber="101" arrival="2025-01-15" departure="2025-01-17" status="confirmed">
<dailySales>
<dailySale date="2025-01-15" revenueTotal="100.00"/>
</dailySales>
</roomReservation>
</roomReservations>
</reservation>
</root>"""
service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await service.process_conversion_xml(xml_content)
# Verify conversion was created
result = await test_db_session.execute(
select(Conversion).where(
Conversion.pms_reservation_id == PMS_RESERVATION_ID
)
)
conversion = result.scalar_one_or_none()
assert conversion is not None, "Conversion should be created"
assert conversion.hotel_id == "hotel_1"
assert conversion.guest_id is not None, "ConversionGuest should be linked"
# Verify conversion_guest was created with the correct data
from sqlalchemy.orm import selectinload
result_with_guest = await test_db_session.execute(
select(Conversion)
.where(Conversion.pms_reservation_id == PMS_RESERVATION_ID)
.options(selectinload(Conversion.guest))
)
conversion_with_guest = result_with_guest.scalar_one_or_none()
assert conversion_with_guest.guest is not None, (
"ConversionGuest relationship should exist"
)
assert conversion_with_guest.guest.guest_first_name == "David"
assert conversion_with_guest.guest.guest_last_name == "Miller"
assert conversion_with_guest.guest.guest_email == "david@example.com"
class TestRegularGuestClassification:
"""Tests for the classify_regular_guests helper."""
@pytest.mark.asyncio
async def test_classify_regular_guest_with_unattributable_history(
self, test_db_session
):
"""Guests with unattributable paying stays become regulars."""
from tests.helpers import MultiReservationXMLBuilder, ReservationXMLBuilder
multi = MultiReservationXMLBuilder()
base_builder = ReservationXMLBuilder(
hotel_id="39054_001",
reservation_id="4001",
reservation_number="REG-1",
reservation_date="2025-05-01",
).set_guest(
guest_id="888",
first_name="Regular",
last_name="Guest",
email="regular@example.com",
)
base_builder.add_room(
arrival="2025-06-01",
departure="2025-06-03",
room_number="71",
status="departed",
revenue_logis_per_day=220.0,
)
multi.add_reservation(base_builder)
second = ReservationXMLBuilder(
hotel_id="39054_001",
reservation_id="4002",
reservation_number="REG-2",
reservation_date="2025-05-10",
).set_guest(
guest_id="888",
first_name="Regular",
last_name="Guest",
email="regular@example.com",
)
second.add_room(
arrival="2025-07-01",
departure="2025-07-04",
room_number="72",
status="departed",
revenue_logis_per_day=210.0,
)
multi.add_reservation(second)
service = ConversionService(test_db_session, hotel_id="39054_001")
await service.process_conversion_xml(multi.build_xml())
stats = await service.classify_regular_guests(updated_within_hours=None)
assert stats["regular"] == 1
guest = await test_db_session.execute(
select(ConversionGuest).where(
ConversionGuest.hotel_id == "39054_001",
ConversionGuest.guest_id == 888,
)
)
guest_record = guest.scalar_one()
assert guest_record.is_regular is True
assert guest_record.is_awareness_guest is False
@pytest.mark.asyncio
async def test_classify_awareness_guest_when_first_stay_attributable(
self, test_db_session
):
"""If the earliest paying stay is attributable, mark awareness guests."""
from tests.helpers import MultiReservationXMLBuilder, ReservationXMLBuilder
multi = MultiReservationXMLBuilder()
first = ReservationXMLBuilder(
hotel_id="39054_001",
reservation_id="4101",
reservation_number="AW-1",
reservation_date="2025-08-01",
).set_guest(
guest_id="889",
first_name="Aware",
last_name="Guest",
email="aware@example.com",
)
first.add_room(
arrival="2025-09-01",
departure="2025-09-03",
room_number="81",
status="departed",
revenue_logis_per_day=250.0,
)
multi.add_reservation(first)
second = ReservationXMLBuilder(
hotel_id="39054_001",
reservation_id="4102",
reservation_number="AW-2",
reservation_date="2025-08-10",
).set_guest(
guest_id="889",
first_name="Aware",
last_name="Guest",
email="aware@example.com",
)
second.add_room(
arrival="2025-10-05",
departure="2025-10-08",
room_number="82",
status="departed",
revenue_logis_per_day=260.0,
)
multi.add_reservation(second)
service = ConversionService(test_db_session, hotel_id="39054_001")
await service.process_conversion_xml(multi.build_xml())
# Mark earliest stay as attributable to simulate campaign match
result = await test_db_session.execute(
select(Conversion)
.where(
Conversion.hotel_id == "39054_001",
Conversion.guest_id == 889,
)
.order_by(Conversion.reservation_date.asc())
)
conversions = result.scalars().all()
conversions[0].directly_attributable = True
conversions[1].directly_attributable = False
await test_db_session.commit()
stats = await service.classify_regular_guests(updated_within_hours=None)
assert stats["regular"] == 1
assert stats["awareness"] == 1
guest = await test_db_session.execute(
select(ConversionGuest).where(
ConversionGuest.hotel_id == "39054_001",
ConversionGuest.guest_id == 889,
)
)
guest_record = guest.scalar_one()
assert guest_record.is_regular is True
assert guest_record.is_awareness_guest is True
@pytest.mark.asyncio
async def test_conversion_guest_composite_key_prevents_duplicates(
self, test_db_session
):
"""Test that ConversionGuest composite primary key (hotel_id, guest_id) prevents duplicates.
With the new schema, the composite PK ensures that each (hotel_id, guest_id) combination
is unique. This prevents the production issue where multiple ConversionGuest records
could exist for the same guest, which previously caused scalar_one_or_none() to fail.
Now the database itself enforces uniqueness at the PK level.
"""
hotel_id = "test_hotel"
guest_id = 123
# Create and commit first conversion guest
guest1 = ConversionGuest.create_from_conversion_data(
hotel_id=hotel_id,
guest_id=guest_id,
guest_first_name="John",
guest_last_name="Doe",
guest_email="john@example.com",
guest_country_code="US",
guest_birth_date=None,
now=None,
)
test_db_session.add(guest1)
await test_db_session.commit()
# Verify guest was created
result = await test_db_session.execute(
select(ConversionGuest).where(
(ConversionGuest.hotel_id == hotel_id)
& (ConversionGuest.guest_id == guest_id)
)
)
guests = result.scalars().all()
assert len(guests) == 1, "Should have created one guest"
assert guests[0].guest_first_name == "John"
# Now try to create a second guest with the SAME (hotel_id, guest_id)
# With composite PK, this should raise an IntegrityError
guest2 = ConversionGuest.create_from_conversion_data(
hotel_id=hotel_id,
guest_id=guest_id,
guest_first_name="Jane", # Different first name
guest_last_name="Doe",
guest_email="jane@example.com",
guest_country_code="US",
guest_birth_date=None,
now=None,
)
test_db_session.add(guest2)
# The composite PK constraint prevents the duplicate insert
from sqlalchemy.exc import IntegrityError
with pytest.raises(IntegrityError):
await test_db_session.commit()
if __name__ == "__main__":
pytest.main([__file__, "-v"])