|
|
@@ -0,0 +1,200 @@
|
|
|
+"""Tests for the two reservation-deletion guard mechanisms.
|
|
|
+
|
|
|
+1. _AirbnbClient._had_bookings — stale-replica cache detection
|
|
|
+2. fetch_reservations per-service guard — skip deletions when a service errors
|
|
|
+ or silently returns 0 reservations while the store has upcoming ones.
|
|
|
+"""
|
|
|
+from datetime import date, timedelta
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from room_cal.reservations import Reservation
|
|
|
+from room_cal.actions.fetch_reservations import fetch_reservations
|
|
|
+
|
|
|
+
|
|
|
+# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+def _airbnb_client():
|
|
|
+ from room_cal.service_api._airbnb import _AirbnbClient
|
|
|
+ return _AirbnbClient(cookies='test', listing_id='12345678')
|
|
|
+
|
|
|
+
|
|
|
+def _days(booked=False):
|
|
|
+ day = {'day': '2026-07-10', 'priceData': {'price': 100}, 'available': True}
|
|
|
+ if booked:
|
|
|
+ day['reservationData'] = {'confirmationCode': 'TEST123'}
|
|
|
+ return [day]
|
|
|
+
|
|
|
+
|
|
|
+class _BaseAPI:
|
|
|
+ """Minimal stub: only get_reservations is called by fetch_reservations."""
|
|
|
+ async def get_reservations(self, start, end, *, _out_errors=None):
|
|
|
+ return []
|
|
|
+
|
|
|
+
|
|
|
+class _ErrorAPI(_BaseAPI):
|
|
|
+ """Simulates a service throwing an exception (e.g. Booking.com 502)."""
|
|
|
+ def __init__(self, error_service: str):
|
|
|
+ self._svc = error_service
|
|
|
+
|
|
|
+ async def get_reservations(self, start, end, *, _out_errors=None):
|
|
|
+ if _out_errors is not None:
|
|
|
+ _out_errors.add(self._svc)
|
|
|
+ return []
|
|
|
+
|
|
|
+
|
|
|
+# Window used by the integration tests — far enough out to not collide with
|
|
|
+# real reservations but still within any plausible sync window.
|
|
|
+_CHECKIN = date.today() + timedelta(days=45)
|
|
|
+_CHECKOUT = _CHECKIN + timedelta(days=2)
|
|
|
+
|
|
|
+
|
|
|
+async def _seed(service: str, rid: str) -> None:
|
|
|
+ await Reservation().set({
|
|
|
+ 'checkin_date': _CHECKIN,
|
|
|
+ 'checkout_date': _CHECKOUT,
|
|
|
+ 'guest_name': 'Test Guest',
|
|
|
+ 'service': service,
|
|
|
+ 'reservation_id': rid,
|
|
|
+ 'service_data': {},
|
|
|
+ })
|
|
|
+
|
|
|
+
|
|
|
+async def _exists(rid: str) -> bool:
|
|
|
+ return await Reservation()._find(rid, _CHECKIN, _CHECKOUT) is not None
|
|
|
+
|
|
|
+
|
|
|
+async def _delete(rid: str) -> None:
|
|
|
+ event = await Reservation()._find(rid, _CHECKIN, _CHECKOUT)
|
|
|
+ if event:
|
|
|
+ await Reservation().delete(event)
|
|
|
+
|
|
|
+
|
|
|
+# ── _had_bookings unit tests ───────────────────────────────────────────────────
|
|
|
+
|
|
|
+class TestHadBookings:
|
|
|
+ """Unit tests for _AirbnbClient._had_bookings stale-cache detection.
|
|
|
+
|
|
|
+ _fetch_month_days is replaced on the instance so no network calls are made.
|
|
|
+ """
|
|
|
+
|
|
|
+ async def test_first_fetch_with_bookings_marks_month(self):
|
|
|
+ client = _airbnb_client()
|
|
|
+ async def _fetch(m, y): return _days(booked=True)
|
|
|
+ client._fetch_month_days = _fetch
|
|
|
+
|
|
|
+ await client._get_month_days(7, 2026)
|
|
|
+ assert (7, 2026) in client._had_bookings
|
|
|
+
|
|
|
+ async def test_first_fetch_without_bookings_does_not_mark(self):
|
|
|
+ client = _airbnb_client()
|
|
|
+ async def _fetch(m, y): return _days(booked=False)
|
|
|
+ client._fetch_month_days = _fetch
|
|
|
+
|
|
|
+ await client._get_month_days(7, 2026)
|
|
|
+ assert (7, 2026) not in client._had_bookings
|
|
|
+
|
|
|
+ async def test_stale_cache_forces_refetch(self):
|
|
|
+ """Cache shows 0 booked but _had_bookings marks month → refetch triggered."""
|
|
|
+ client = _airbnb_client()
|
|
|
+ key = (7, 2026)
|
|
|
+ client._had_bookings.add(key)
|
|
|
+ client._raw_months[key] = _days(booked=False) # stale: no bookings
|
|
|
+
|
|
|
+ calls = []
|
|
|
+ async def _fetch(m, y):
|
|
|
+ calls.append((m, y))
|
|
|
+ return _days(booked=True)
|
|
|
+ client._fetch_month_days = _fetch
|
|
|
+
|
|
|
+ result = await client._get_month_days(7, 2026)
|
|
|
+ assert calls == [(7, 2026)], "expected exactly one refetch"
|
|
|
+ assert any(d.get('reservationData') for d in result)
|
|
|
+
|
|
|
+ async def test_no_refetch_without_prior_bookings(self):
|
|
|
+ """Cache has 0 booked and _had_bookings has no entry → served from cache."""
|
|
|
+ client = _airbnb_client()
|
|
|
+ key = (7, 2026)
|
|
|
+ client._raw_months[key] = _days(booked=False)
|
|
|
+
|
|
|
+ calls = []
|
|
|
+ async def _fetch(m, y):
|
|
|
+ calls.append((m, y))
|
|
|
+ return _days(booked=True)
|
|
|
+ client._fetch_month_days = _fetch
|
|
|
+
|
|
|
+ await client._get_month_days(7, 2026)
|
|
|
+ assert calls == [], "cache hit should not trigger refetch"
|
|
|
+
|
|
|
+ async def test_forced_refetch_confirms_empty_removes_mark(self):
|
|
|
+ """Forced refetch still shows 0 booked → month removed from _had_bookings."""
|
|
|
+ client = _airbnb_client()
|
|
|
+ key = (7, 2026)
|
|
|
+ client._had_bookings.add(key)
|
|
|
+ client._raw_months[key] = _days(booked=False)
|
|
|
+
|
|
|
+ async def _fetch(m, y): return _days(booked=False) # still 0 on refetch
|
|
|
+ client._fetch_month_days = _fetch
|
|
|
+
|
|
|
+ await client._get_month_days(7, 2026)
|
|
|
+ assert key not in client._had_bookings, "confirmed empty — guard should be lifted"
|
|
|
+
|
|
|
+ async def test_refetch_invalidates_fetched_months_and_cache(self):
|
|
|
+ """Forcing a refetch must also clear _fetched_months and _cache for that month."""
|
|
|
+ from room_cal.service_api.types import DayInfo
|
|
|
+ _dummy = DayInfo(date='2026-07-10', price=100, available=True, booked=False, rooms_to_sell=None)
|
|
|
+
|
|
|
+ client = _airbnb_client()
|
|
|
+ key = (7, 2026)
|
|
|
+ client._had_bookings.add(key)
|
|
|
+ client._raw_months[key] = _days(booked=False)
|
|
|
+ client._fetched_months.add(key)
|
|
|
+ client._cache['2026-07-10'] = _dummy
|
|
|
+ client._cache['2026-08-01'] = _dummy # different month — must survive
|
|
|
+
|
|
|
+ async def _fetch(m, y): return _days(booked=True)
|
|
|
+ client._fetch_month_days = _fetch
|
|
|
+
|
|
|
+ await client._get_month_days(7, 2026)
|
|
|
+ assert key not in client._fetched_months
|
|
|
+ assert '2026-07-10' not in client._cache
|
|
|
+ assert '2026-08-01' in client._cache, "different month must not be evicted"
|
|
|
+
|
|
|
+ async def test_had_bookings_survives_clear_cache(self):
|
|
|
+ """_had_bookings must not be cleared by clear_cache() — that's the whole point."""
|
|
|
+ client = _airbnb_client()
|
|
|
+ client._had_bookings.add((7, 2026))
|
|
|
+ client.clear_cache()
|
|
|
+ assert (7, 2026) in client._had_bookings
|
|
|
+
|
|
|
+
|
|
|
+# ── fetch_reservations per-service guard integration tests ────────────────────
|
|
|
+
|
|
|
+class TestFetchReservationsGuard:
|
|
|
+ """Integration tests: real Reservation store, stub API."""
|
|
|
+
|
|
|
+ async def test_errored_service_skips_deletion(self):
|
|
|
+ """When a service throws (e.g. 502), its stored reservations are preserved."""
|
|
|
+ rid = 'TEST-GUARD-ERROR-001'
|
|
|
+ await _seed('airbnb', rid)
|
|
|
+ try:
|
|
|
+ await fetch_reservations(_CHECKIN, _CHECKOUT, api=_ErrorAPI('airbnb'))
|
|
|
+ assert await _exists(rid), "reservation must survive when its service errored"
|
|
|
+ finally:
|
|
|
+ await _delete(rid)
|
|
|
+
|
|
|
+ async def test_silent_zero_return_skips_deletion(self):
|
|
|
+ """When a service returns [] with stored upcoming reservations, those are preserved."""
|
|
|
+ rid = 'TEST-GUARD-ZERO-001'
|
|
|
+ await _seed('airbnb', rid)
|
|
|
+ try:
|
|
|
+ await fetch_reservations(_CHECKIN, _CHECKOUT, api=_BaseAPI())
|
|
|
+ assert await _exists(rid), "reservation must survive when service silently returned 0"
|
|
|
+ finally:
|
|
|
+ await _delete(rid)
|
|
|
+
|
|
|
+ async def test_no_guard_when_store_is_empty(self):
|
|
|
+ """Guard does not fire when there are no stored reservations to protect."""
|
|
|
+ # Nothing seeded — fetch with empty API should succeed without error.
|
|
|
+ result = await fetch_reservations(_CHECKIN, _CHECKOUT, api=_BaseAPI())
|
|
|
+ assert result.data == []
|