"""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 == []