test_reservation_guards.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. """Tests for the reservation-deletion guard mechanisms.
  2. 1. _AirbnbClient._had_bookings — stale-replica cache detection
  3. 2. fetch_reservations per-service guard — skip deletions when a service errors
  4. or silently returns 0 reservations while the store has upcoming ones.
  5. 3. fetch_reservations mass-deletion guard — skip deletions when a single run
  6. would remove more than _MAX_DELETIONS_PER_RUN reservations for a service.
  7. """
  8. import time
  9. from datetime import date, timedelta
  10. import pytest
  11. from room_cal.reservations import Reservation
  12. from room_cal.actions.fetch_reservations import fetch_reservations
  13. # ── Helpers ───────────────────────────────────────────────────────────────────
  14. def _airbnb_client():
  15. from room_cal.service_api._airbnb import _AirbnbClient
  16. return _AirbnbClient(cookies='test', listing_id='12345678')
  17. def _days(booked=False):
  18. day = {'day': '2026-07-10', 'priceData': {'price': 100}, 'available': True}
  19. if booked:
  20. day['reservationData'] = {'confirmationCode': 'TEST123'}
  21. return [day]
  22. class _BaseAPI:
  23. """Minimal stub: only get_reservations is called by fetch_reservations."""
  24. async def get_reservations(self, start, end, *, _out_errors=None):
  25. return []
  26. class _ErrorAPI(_BaseAPI):
  27. """Simulates a service throwing an exception (e.g. Booking.com 502)."""
  28. def __init__(self, error_service: str):
  29. self._svc = error_service
  30. async def get_reservations(self, start, end, *, _out_errors=None):
  31. if _out_errors is not None:
  32. _out_errors.add(self._svc)
  33. return []
  34. class _PartialAPI(_BaseAPI):
  35. """Simulates a service returning a partial reservation list (stale replica)."""
  36. def __init__(self, reservations: list[dict]):
  37. self._res = reservations
  38. async def get_reservations(self, start, end, *, _out_errors=None):
  39. return self._res
  40. class _StatusCheckAPI(_BaseAPI):
  41. """Simulates a client implementing the check_reservation_status capability."""
  42. def __init__(self, statuses: dict[str, bool | None]):
  43. self._statuses = statuses
  44. async def check_reservation_status(self, service, reservation_id):
  45. return self._statuses.get(reservation_id)
  46. class _NoopClosed:
  47. """Stand-in for Closed so guard tests don't write closure events to the real store."""
  48. def __init__(self, *a, **k): pass
  49. async def list(self, *a, **k): return []
  50. async def set(self, *a, **k): pass
  51. async def delete(self, *a, **k): pass
  52. # Window used by the integration tests — far enough out to not collide with
  53. # real reservations but still within any plausible sync window.
  54. _CHECKIN = date.today() + timedelta(days=45)
  55. _CHECKOUT = _CHECKIN + timedelta(days=2)
  56. async def _seed(service: str, rid: str) -> None:
  57. await Reservation().set({
  58. 'checkin_date': _CHECKIN,
  59. 'checkout_date': _CHECKOUT,
  60. 'guest_name': 'Test Guest',
  61. 'service': service,
  62. 'reservation_id': rid,
  63. 'service_data': {},
  64. })
  65. async def _exists(rid: str) -> bool:
  66. return await Reservation()._find(rid, _CHECKIN, _CHECKOUT) is not None
  67. async def _delete(rid: str) -> None:
  68. event = await Reservation()._find(rid, _CHECKIN, _CHECKOUT)
  69. if event:
  70. await Reservation().delete(event)
  71. def _remote(service: str, rid: str) -> dict:
  72. """Reservation dict as ServiceAPI.get_reservations would return it."""
  73. return {
  74. 'id': rid,
  75. 'service': service,
  76. 'start_date': _CHECKIN,
  77. 'end_date': _CHECKOUT,
  78. 'guest_name': 'Test Guest',
  79. 'service_data': {},
  80. 'chat_id': None,
  81. 'door_code': None,
  82. }
  83. # ── _had_bookings unit tests ───────────────────────────────────────────────────
  84. class TestHadBookings:
  85. """Unit tests for _AirbnbClient._had_bookings stale-cache detection.
  86. _fetch_month_days is replaced on the instance so no network calls are made.
  87. """
  88. @staticmethod
  89. def _seed(client, key, days):
  90. client._months._data[key] = days
  91. client._months._fetched_at[key] = time.monotonic()
  92. async def test_first_fetch_with_bookings_marks_month(self):
  93. client = _airbnb_client()
  94. async def _fetch(m, y): return _days(booked=True)
  95. client._fetch_month_days = _fetch
  96. await client._months.get(7, 2026)
  97. assert (7, 2026) in client._had_bookings
  98. async def test_first_fetch_without_bookings_does_not_mark(self):
  99. client = _airbnb_client()
  100. async def _fetch(m, y): return _days(booked=False)
  101. client._fetch_month_days = _fetch
  102. await client._months.get(7, 2026)
  103. assert (7, 2026) not in client._had_bookings
  104. async def test_stale_cache_forces_refetch(self):
  105. """Cache shows 0 booked but _had_bookings marks month → refetch triggered."""
  106. client = _airbnb_client()
  107. key = (7, 2026)
  108. client._had_bookings.add(key)
  109. self._seed(client, key, _days(booked=False)) # stale: no bookings
  110. calls = []
  111. async def _fetch(m, y):
  112. calls.append((m, y))
  113. return _days(booked=True)
  114. client._fetch_month_days = _fetch
  115. result = await client._months.get(7, 2026)
  116. assert calls == [(7, 2026)], "expected exactly one refetch"
  117. assert any(d.get('reservationData') for d in result)
  118. async def test_no_refetch_without_prior_bookings(self):
  119. """Cache has 0 booked and _had_bookings has no entry → served from cache."""
  120. client = _airbnb_client()
  121. key = (7, 2026)
  122. self._seed(client, key, _days(booked=False))
  123. calls = []
  124. async def _fetch(m, y):
  125. calls.append((m, y))
  126. return _days(booked=True)
  127. client._fetch_month_days = _fetch
  128. await client._months.get(7, 2026)
  129. assert calls == [], "cache hit should not trigger refetch"
  130. async def test_forced_refetch_confirms_empty_removes_mark(self):
  131. """Forced refetch still shows 0 booked → month removed from _had_bookings."""
  132. client = _airbnb_client()
  133. key = (7, 2026)
  134. client._had_bookings.add(key)
  135. self._seed(client, key, _days(booked=False))
  136. async def _fetch(m, y): return _days(booked=False) # still 0 on refetch
  137. client._fetch_month_days = _fetch
  138. await client._months.get(7, 2026)
  139. assert key not in client._had_bookings, "confirmed empty — guard should be lifted"
  140. async def test_refetch_invalidates_parsed_months_and_cache(self):
  141. """Forcing a refetch must also clear _parsed_months and _cache for that month."""
  142. from room_cal.service_api.types import DayInfo
  143. _dummy = DayInfo(date='2026-07-10', price=100, available=True, booked=False)
  144. client = _airbnb_client()
  145. key = (7, 2026)
  146. client._had_bookings.add(key)
  147. self._seed(client, key, _days(booked=False))
  148. client._parsed_months.add(key)
  149. client._cache['2026-07-10'] = _dummy
  150. client._cache['2026-08-01'] = _dummy # different month — must survive
  151. async def _fetch(m, y): return _days(booked=True)
  152. client._fetch_month_days = _fetch
  153. await client._months.get(7, 2026)
  154. assert key not in client._parsed_months
  155. assert '2026-07-10' not in client._cache
  156. assert '2026-08-01' in client._cache, "different month must not be evicted"
  157. async def test_had_bookings_survives_clear_cache(self):
  158. """_had_bookings must not be cleared by clear_cache() — that's the whole point."""
  159. client = _airbnb_client()
  160. client._had_bookings.add((7, 2026))
  161. client.clear_cache()
  162. assert (7, 2026) in client._had_bookings
  163. # ── fetch_reservations per-service guard integration tests ────────────────────
  164. class TestFetchReservationsGuard:
  165. """Integration tests: real Reservation store, stub API."""
  166. async def test_errored_service_skips_deletion(self):
  167. """When a service throws (e.g. 502), its stored reservations are preserved."""
  168. rid = 'TEST-GUARD-ERROR-001'
  169. await _seed('airbnb', rid)
  170. try:
  171. await fetch_reservations(_CHECKIN, _CHECKOUT, api=_ErrorAPI('airbnb'))
  172. assert await _exists(rid), "reservation must survive when its service errored"
  173. finally:
  174. await _delete(rid)
  175. async def test_silent_zero_return_skips_deletion(self):
  176. """When a service returns [] with stored upcoming reservations, those are preserved."""
  177. rid = 'TEST-GUARD-ZERO-001'
  178. await _seed('airbnb', rid)
  179. try:
  180. await fetch_reservations(_CHECKIN, _CHECKOUT, api=_BaseAPI())
  181. assert await _exists(rid), "reservation must survive when service silently returned 0"
  182. finally:
  183. await _delete(rid)
  184. async def test_no_guard_when_store_is_empty(self):
  185. """Guard does not fire when there are no stored reservations to protect."""
  186. # Nothing seeded — fetch with empty API should succeed without error.
  187. result = await fetch_reservations(_CHECKIN, _CHECKOUT, api=_BaseAPI())
  188. assert result.data == []
  189. async def test_status_check_confirms_cancellation_despite_zero_return(self):
  190. """check_reservation_status confirming True lets deletion proceed even with 0 returned."""
  191. rid = 'TEST-GUARD-STATUS-CONFIRMED-001'
  192. await _seed('airbnb', rid)
  193. try:
  194. await fetch_reservations(_CHECKIN, _CHECKOUT, api=_StatusCheckAPI({rid: True}))
  195. assert not await _exists(rid), "status check confirming canceled must allow deletion"
  196. finally:
  197. await _delete(rid)
  198. async def test_status_check_ambiguous_falls_back_to_guard(self):
  199. """check_reservation_status returning None (ambiguous) still guards, like no capability at all."""
  200. rid = 'TEST-GUARD-STATUS-AMBIGUOUS-001'
  201. await _seed('airbnb', rid)
  202. try:
  203. await fetch_reservations(_CHECKIN, _CHECKOUT, api=_StatusCheckAPI({rid: None}))
  204. assert await _exists(rid), "ambiguous status must not authorize deletion"
  205. finally:
  206. await _delete(rid)
  207. # ── fetch_reservations mass-deletion guard integration tests ──────────────────
  208. class TestMassDeletionGuard:
  209. """Integration tests: real Reservation store, stub API returning partial data.
  210. Closed is replaced with a no-op so the tests never write closure events to
  211. the store. The API must return at least one reservation for the service —
  212. otherwise the silent-zero guard fires before the mass-deletion guard.
  213. """
  214. @pytest.fixture(autouse=True)
  215. def _noop_closed(self, monkeypatch):
  216. monkeypatch.setattr('room_cal.actions.fetch_reservations.Closed', _NoopClosed)
  217. async def test_mass_deletion_is_cancelled(self):
  218. """More than _MAX_DELETIONS_PER_RUN pending deletions → all are skipped."""
  219. rids = [f'TEST-GUARD-MASS-00{i}' for i in range(1, 5)]
  220. for rid in rids:
  221. await _seed('airbnb', rid)
  222. try:
  223. # API returns only the first reservation → 3 pending deletions (> 2).
  224. await fetch_reservations(_CHECKIN, _CHECKOUT, api=_PartialAPI([_remote('airbnb', rids[0])]))
  225. for rid in rids:
  226. assert await _exists(rid), f"{rid} must survive a mass-deletion run"
  227. finally:
  228. for rid in rids:
  229. await _delete(rid)
  230. async def test_deletions_at_threshold_proceed(self):
  231. """Exactly _MAX_DELETIONS_PER_RUN pending deletions → deleted normally."""
  232. rids = [f'TEST-GUARD-OK-00{i}' for i in range(1, 4)]
  233. for rid in rids:
  234. await _seed('airbnb', rid)
  235. try:
  236. # API returns only the first reservation → 2 pending deletions (== max).
  237. await fetch_reservations(_CHECKIN, _CHECKOUT, api=_PartialAPI([_remote('airbnb', rids[0])]))
  238. assert await _exists(rids[0]), "returned reservation must remain"
  239. assert not await _exists(rids[1]), "genuine cancellation must be deleted"
  240. assert not await _exists(rids[2]), "genuine cancellation must be deleted"
  241. finally:
  242. for rid in rids:
  243. await _delete(rid)