878 lines
33 KiB
Python
878 lines
33 KiB
Python
"""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 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)
|
||
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)
|
||
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)
|
||
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)
|
||
|
||
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)
|
||
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)
|
||
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 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)
|
||
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)
|
||
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)
|
||
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()
|
||
|
||
hashed_customer = customer.create_hashed_customer()
|
||
test_db_session.add(hashed_customer)
|
||
await test_db_session.flush()
|
||
|
||
reservation = Reservation(
|
||
customer_id=customer.id,
|
||
unique_id="res_6",
|
||
hotel_code="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="hotel_1")
|
||
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"
|
||
|
||
# Verify conversion_room was created
|
||
room_result = await test_db_session.execute(
|
||
select(ConversionRoom).where(ConversionRoom.conversion_id == conversion.id)
|
||
)
|
||
rooms = room_result.scalars().all()
|
||
assert len(rooms) > 0, "ConversionRoom should be created"
|
||
|
||
# Verify matching occurred (may or may not have matched depending on data)
|
||
# The important thing is that the records exist
|
||
assert stats["total_reservations"] == 1
|
||
assert stats["total_daily_sales"] == 1
|
||
|
||
@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"])
|