"""Tests for the 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. 3. fetch_reservations mass-deletion guard — skip deletions when a single run would remove more than _MAX_DELETIONS_PER_RUN reservations for a service. """ import time 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 [] class _PartialAPI(_BaseAPI): """Simulates a service returning a partial reservation list (stale replica).""" def __init__(self, reservations: list[dict]): self._res = reservations async def get_reservations(self, start, end, *, _out_errors=None): return self._res class _StatusCheckAPI(_BaseAPI): """Simulates a client implementing the check_reservation_status capability.""" def __init__(self, statuses: dict[str, bool | None]): self._statuses = statuses async def check_reservation_status(self, service, reservation_id): return self._statuses.get(reservation_id) class _NoopClosed: """Stand-in for Closed so guard tests don't write closure events to the real store.""" def __init__(self, *a, **k): pass async def list(self, *a, **k): return [] async def set(self, *a, **k): pass async def delete(self, *a, **k): pass # 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) def _remote(service: str, rid: str) -> dict: """Reservation dict as ServiceAPI.get_reservations would return it.""" return { 'id': rid, 'service': service, 'start_date': _CHECKIN, 'end_date': _CHECKOUT, 'guest_name': 'Test Guest', 'service_data': {}, 'chat_id': None, 'door_code': None, } # ── _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. """ @staticmethod def _seed(client, key, days): client._months._data[key] = days client._months._fetched_at[key] = time.monotonic() 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._months.get(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._months.get(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) self._seed(client, 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._months.get(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) self._seed(client, 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._months.get(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) self._seed(client, key, _days(booked=False)) async def _fetch(m, y): return _days(booked=False) # still 0 on refetch client._fetch_month_days = _fetch await client._months.get(7, 2026) assert key not in client._had_bookings, "confirmed empty — guard should be lifted" async def test_refetch_invalidates_parsed_months_and_cache(self): """Forcing a refetch must also clear _parsed_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) client = _airbnb_client() key = (7, 2026) client._had_bookings.add(key) self._seed(client, key, _days(booked=False)) client._parsed_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._months.get(7, 2026) assert key not in client._parsed_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 == [] async def test_status_check_confirms_cancellation_despite_zero_return(self): """check_reservation_status confirming True lets deletion proceed even with 0 returned.""" rid = 'TEST-GUARD-STATUS-CONFIRMED-001' await _seed('airbnb', rid) try: await fetch_reservations(_CHECKIN, _CHECKOUT, api=_StatusCheckAPI({rid: True})) assert not await _exists(rid), "status check confirming canceled must allow deletion" finally: await _delete(rid) async def test_status_check_ambiguous_falls_back_to_guard(self): """check_reservation_status returning None (ambiguous) still guards, like no capability at all.""" rid = 'TEST-GUARD-STATUS-AMBIGUOUS-001' await _seed('airbnb', rid) try: await fetch_reservations(_CHECKIN, _CHECKOUT, api=_StatusCheckAPI({rid: None})) assert await _exists(rid), "ambiguous status must not authorize deletion" finally: await _delete(rid) # ── fetch_reservations mass-deletion guard integration tests ────────────────── class TestMassDeletionGuard: """Integration tests: real Reservation store, stub API returning partial data. Closed is replaced with a no-op so the tests never write closure events to the store. The API must return at least one reservation for the service — otherwise the silent-zero guard fires before the mass-deletion guard. """ @pytest.fixture(autouse=True) def _noop_closed(self, monkeypatch): monkeypatch.setattr('room_cal.actions.fetch_reservations.Closed', _NoopClosed) async def test_mass_deletion_is_cancelled(self): """More than _MAX_DELETIONS_PER_RUN pending deletions → all are skipped.""" rids = [f'TEST-GUARD-MASS-00{i}' for i in range(1, 5)] for rid in rids: await _seed('airbnb', rid) try: # API returns only the first reservation → 3 pending deletions (> 2). await fetch_reservations(_CHECKIN, _CHECKOUT, api=_PartialAPI([_remote('airbnb', rids[0])])) for rid in rids: assert await _exists(rid), f"{rid} must survive a mass-deletion run" finally: for rid in rids: await _delete(rid) async def test_deletions_at_threshold_proceed(self): """Exactly _MAX_DELETIONS_PER_RUN pending deletions → deleted normally.""" rids = [f'TEST-GUARD-OK-00{i}' for i in range(1, 4)] for rid in rids: await _seed('airbnb', rid) try: # API returns only the first reservation → 2 pending deletions (== max). await fetch_reservations(_CHECKIN, _CHECKOUT, api=_PartialAPI([_remote('airbnb', rids[0])])) assert await _exists(rids[0]), "returned reservation must remain" assert not await _exists(rids[1]), "genuine cancellation must be deleted" assert not await _exists(rids[2]), "genuine cancellation must be deleted" finally: for rid in rids: await _delete(rid)