Merge branch 'db_fixes_plus_free_rooms' of https://gitea.99tales.net/jonas/alpinebits_python into db_fixes_plus_free_rooms

This commit is contained in:
Jonas Linter
2025-12-03 22:37:08 +01:00
27 changed files with 1998 additions and 957 deletions

View File

@@ -98,7 +98,7 @@ def sample_reservation(sample_customer):
user_comment="Late check-in requested",
fbclid="PAZXh0bgNhZW0BMABhZGlkAasmYBTNE3QBp1jWuJ9zIpfEGRJMP63fMAMI405yvG5EtH-OT0PxSkAbBJaudFHR6cMtkdHu_aem_fopaFtECyVPNW9fmWfEkyA",
gclid="",
hotel_code="HOTEL123",
hotel_id="HOTEL123",
hotel_name="Alpine Paradise Resort",
)
data = reservation.model_dump(exclude_none=True)
@@ -136,7 +136,7 @@ def minimal_reservation(minimal_customer):
num_adults=1,
num_children=0,
children_ages=[],
hotel_code="HOTEL123",
hotel_id="HOTEL123",
created_at=datetime(2024, 12, 2, 12, 0, 0, tzinfo=UTC),
hotel_name="Alpine Paradise Resort",
)
@@ -403,7 +403,7 @@ class TestEdgeCases:
num_adults=1,
num_children=0,
children_ages="",
hotel_code="HOTEL123",
hotel_id="HOTEL123",
created_at=datetime.now(UTC),
)
@@ -434,7 +434,7 @@ class TestEdgeCases:
num_adults=2,
num_children=0,
children_ages=[],
hotel_code="HOTEL123",
hotel_id="HOTEL123",
created_at=datetime.now(UTC),
utm_source="facebook",
utm_medium="social",
@@ -851,7 +851,7 @@ class TestAcknowledgments:
num_adults=2,
num_children=0,
children_ages=[],
hotel_code="HOTEL123",
hotel_id="HOTEL123",
hotel_name="Alpine Paradise Resort",
created_at=datetime(2024, 11, 1, 12, 0, 0, tzinfo=UTC),
)
@@ -863,7 +863,7 @@ class TestAcknowledgments:
num_adults=2,
num_children=1,
children_ages=[10],
hotel_code="HOTEL123",
hotel_id="HOTEL123",
hotel_name="Alpine Paradise Resort",
created_at=datetime(2024, 11, 15, 10, 0, 0, tzinfo=UTC),
)

View File

@@ -523,7 +523,7 @@ class TestGenericWebhookEndpoint:
(r for r in reservations if r.customer_id == customer.id), None
)
assert reservation is not None, "Reservation should be created"
assert reservation.hotel_code == "HOTEL123"
assert reservation.hotel_id == "HOTEL123"
assert reservation.hotel_name == "Test Hotel"
assert reservation.num_adults == 2
assert reservation.num_children == 1
@@ -614,7 +614,7 @@ class TestGenericWebhookEndpoint:
result = await session.execute(select(Reservation))
reservations = result.scalars().all()
reservation = next(
(r for r in reservations if r.hotel_code == "HOTEL123"), None
(r for r in reservations if r.hotel_id == "HOTEL123"), None
)
assert reservation is not None, "Reservation should be created"
assert reservation.num_children == 3

View File

@@ -20,6 +20,7 @@ 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
@@ -142,7 +143,7 @@ class TestConversionServiceWithImportedData:
## Need to check if reservations and customers are now actually available in the db before proceeding
conversion_service = ConversionService(test_db_session)
conversion_service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await conversion_service.process_conversion_xml(xml_content)
# BASELINE ASSERTIONS:
@@ -224,7 +225,7 @@ class TestConversionServiceWithImportedData:
# File already has proper XML structure, just use it as-is
xml_content = xml_content.strip()
conversion_service = ConversionService(test_db_session)
conversion_service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await conversion_service.process_conversion_xml(xml_content)
# Verify conversions were created
@@ -300,7 +301,7 @@ class TestConversionServiceWithImportedData:
# File already has proper XML structure, just use it as-is
xml_content = xml_content.strip()
conversion_service = ConversionService(test_db_session)
conversion_service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await conversion_service.process_conversion_xml(xml_content)
# Verify conversions were processed
@@ -332,7 +333,7 @@ class TestConversionServiceWithImportedData:
"""Test ConversionService handles invalid XML gracefully."""
invalid_xml = "<invalid>unclosed tag"
conversion_service = ConversionService(test_db_session)
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)
@@ -342,7 +343,7 @@ class TestConversionServiceWithImportedData:
"""Test ConversionService handles empty/minimal XML."""
minimal_xml = '<?xml version="1.0"?><root></root>'
conversion_service = ConversionService(test_db_session)
conversion_service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await conversion_service.process_conversion_xml(minimal_xml)
assert stats["total_reservations"] == 0
@@ -421,7 +422,7 @@ class TestConversionServiceWithImportedData:
xml_content1 = multi_builder1.build_xml()
# Process first batch
service = ConversionService(test_db_session)
service = ConversionService(test_db_session, hotel_id="39054_001")
stats1 = await service.process_conversion_xml(xml_content1)
assert stats1["total_reservations"] == 2
@@ -542,6 +543,187 @@ class TestConversionServiceWithImportedData:
)
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."""
@@ -577,7 +759,7 @@ class TestXMLBuilderUsage:
)
# Process the XML
service = ConversionService(test_db_session)
service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await service.process_conversion_xml(xml_content)
assert stats["total_reservations"] == 1
@@ -616,7 +798,7 @@ class TestXMLBuilderUsage:
.build_xml()
)
service = ConversionService(test_db_session)
service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await service.process_conversion_xml(xml_content)
assert stats["total_reservations"] == 1
@@ -677,7 +859,7 @@ class TestXMLBuilderUsage:
xml_content = multi_builder.build_xml()
# Process the XML
service = ConversionService(test_db_session)
service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await service.process_conversion_xml(xml_content)
assert stats["total_reservations"] == 2
@@ -740,14 +922,13 @@ class TestHashedMatchingLogic:
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()
customer.update_hashed_fields()
reservation = Reservation(
customer_id=customer.id,
unique_id="res_6",
hotel_code="hotel_1",
hotel_id="hotel_1",
)
test_db_session.add(reservation)
await test_db_session.commit()
@@ -769,7 +950,7 @@ class TestHashedMatchingLogic:
</reservation>
</root>"""
service = ConversionService(test_db_session, hotel_id="hotel_1")
service = ConversionService(test_db_session, hotel_id="39054_001")
stats = await service.process_conversion_xml(xml_content)
# Verify conversion was created
@@ -800,17 +981,152 @@ class TestHashedMatchingLogic:
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
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(

View File

@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from alpine_bits_python.customer_service import CustomerService
from alpine_bits_python.db import Base, Customer, HashedCustomer
from alpine_bits_python.db import Base, Customer
@pytest_asyncio.fixture
@@ -42,9 +42,9 @@ async def test_create_customer_creates_hashed_version(async_session: AsyncSessio
assert customer.given_name == "John"
# Check that hashed version was created
hashed = await service.get_hashed_customer(customer.id)
hashed = await service.get_customer(customer.id)
assert hashed is not None
assert hashed.customer_id == customer.id
assert hashed.id == customer.id
assert hashed.hashed_email is not None
assert hashed.hashed_phone is not None
assert hashed.hashed_given_name is not None
@@ -66,7 +66,7 @@ async def test_update_customer_updates_hashed_version(async_session: AsyncSessio
customer = await service.create_customer(customer_data)
# Get initial hashed email
hashed = await service.get_hashed_customer(customer.id)
hashed = await service.get_customer(customer.id)
original_hashed_email = hashed.hashed_email
# Update customer email
@@ -74,7 +74,7 @@ async def test_update_customer_updates_hashed_version(async_session: AsyncSessio
updated_customer = await service.update_customer(customer, update_data)
# Check that hashed version was updated
updated_hashed = await service.get_hashed_customer(updated_customer.id)
updated_hashed = await service.get_customer(updated_customer.id)
assert updated_hashed.hashed_email != original_hashed_email
@@ -95,7 +95,7 @@ async def test_get_or_create_customer_creates_new(async_session: AsyncSession):
assert customer.contact_id == "new123"
# Verify hashed version exists
hashed = await service.get_hashed_customer(customer.id)
hashed = await service.get_customer(customer.id)
assert hashed is not None
@@ -145,10 +145,13 @@ async def test_hash_existing_customers_backfills(async_session: AsyncSession):
# Verify no hashed version exists
result = await async_session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer.id)
select(Customer).where(Customer.id == customer.id)
)
hashed = result.scalar_one_or_none()
assert hashed is None
assert hashed, "Customer should exist."
assert hashed.hashed_given_name is None, "Hashed given name should be None."
assert hashed.hashed_email is None, "Hashed email should be None."
# Run backfill
service = CustomerService(async_session)
@@ -158,11 +161,12 @@ async def test_hash_existing_customers_backfills(async_session: AsyncSession):
# Verify hashed version now exists
result = await async_session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer.id)
select(Customer).where(Customer.id == customer.id)
)
hashed = result.scalar_one_or_none()
assert hashed is not None
assert hashed.hashed_email is not None
assert hashed is not None, "Customer should still exist after backfill."
assert hashed.hashed_email is not None, "Hashed email should be populated."
assert hashed.hashed_given_name is not None, "Hashed given name should be populated."
@pytest.mark.asyncio
@@ -201,7 +205,7 @@ async def test_hashing_normalization(async_session: AsyncSession):
}
customer = await service.create_customer(customer_data)
hashed = await service.get_hashed_customer(customer.id)
hashed = await service.get_customer(customer.id)
# Verify hashes exist (normalization should have occurred)
assert hashed.hashed_email is not None
@@ -244,13 +248,17 @@ async def test_hash_existing_customers_normalizes_country_code(
# Verify no hashed version exists yet
result = await async_session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer.id)
select(Customer).where(Customer.id == customer.id)
)
hashed = result.scalar_one_or_none()
assert hashed is None
assert hashed is not None, "Customer should exist."
assert hashed.hashed_given_name is None, "Hashed given name should be None."
assert hashed.hashed_email is None, "Hashed email should be None."
assert hashed.hashed_country_code is None, "Hashed country code should be None."
# Verify the customer has the invalid country code stored in the DB
assert customer.country_code == "Italy"
assert hashed.country_code == "Italy"
# Run hash_existing_customers - this should normalize "Italy" to "IT"
# during validation and successfully create a hashed customer
@@ -263,7 +271,7 @@ async def test_hash_existing_customers_normalizes_country_code(
# Verify hashed version was created
await async_session.refresh(customer)
result = await async_session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer.id)
select(Customer).where(Customer.id == customer.id)
)
hashed = result.scalar_one_or_none()
assert hashed is not None
@@ -302,7 +310,7 @@ async def test_hash_existing_customers_normalizes_country_code(
# Verify hashed version was created with correct hash
result = await async_session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer2.id)
select(Customer).where(Customer.id == customer2.id)
)
hashed = result.scalar_one_or_none()
assert hashed is not None