test_reservation_guards.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """Tests for the two 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. """
  6. from datetime import date, timedelta
  7. import pytest
  8. from room_cal.reservations import Reservation
  9. from room_cal.actions.fetch_reservations import fetch_reservations
  10. # ── Helpers ───────────────────────────────────────────────────────────────────
  11. def _airbnb_client():
  12. from room_cal.service_api._airbnb import _AirbnbClient
  13. return _AirbnbClient(cookies='test', listing_id='12345678')
  14. def _days(booked=False):
  15. day = {'day': '2026-07-10', 'priceData': {'price': 100}, 'available': True}
  16. if booked:
  17. day['reservationData'] = {'confirmationCode': 'TEST123'}
  18. return [day]
  19. class _BaseAPI:
  20. """Minimal stub: only get_reservations is called by fetch_reservations."""
  21. async def get_reservations(self, start, end, *, _out_errors=None):
  22. return []
  23. class _ErrorAPI(_BaseAPI):
  24. """Simulates a service throwing an exception (e.g. Booking.com 502)."""
  25. def __init__(self, error_service: str):
  26. self._svc = error_service
  27. async def get_reservations(self, start, end, *, _out_errors=None):
  28. if _out_errors is not None:
  29. _out_errors.add(self._svc)
  30. return []
  31. # Window used by the integration tests — far enough out to not collide with
  32. # real reservations but still within any plausible sync window.
  33. _CHECKIN = date.today() + timedelta(days=45)
  34. _CHECKOUT = _CHECKIN + timedelta(days=2)
  35. async def _seed(service: str, rid: str) -> None:
  36. await Reservation().set({
  37. 'checkin_date': _CHECKIN,
  38. 'checkout_date': _CHECKOUT,
  39. 'guest_name': 'Test Guest',
  40. 'service': service,
  41. 'reservation_id': rid,
  42. 'service_data': {},
  43. })
  44. async def _exists(rid: str) -> bool:
  45. return await Reservation()._find(rid, _CHECKIN, _CHECKOUT) is not None
  46. async def _delete(rid: str) -> None:
  47. event = await Reservation()._find(rid, _CHECKIN, _CHECKOUT)
  48. if event:
  49. await Reservation().delete(event)
  50. # ── _had_bookings unit tests ───────────────────────────────────────────────────
  51. class TestHadBookings:
  52. """Unit tests for _AirbnbClient._had_bookings stale-cache detection.
  53. _fetch_month_days is replaced on the instance so no network calls are made.
  54. """
  55. async def test_first_fetch_with_bookings_marks_month(self):
  56. client = _airbnb_client()
  57. async def _fetch(m, y): return _days(booked=True)
  58. client._fetch_month_days = _fetch
  59. await client._get_month_days(7, 2026)
  60. assert (7, 2026) in client._had_bookings
  61. async def test_first_fetch_without_bookings_does_not_mark(self):
  62. client = _airbnb_client()
  63. async def _fetch(m, y): return _days(booked=False)
  64. client._fetch_month_days = _fetch
  65. await client._get_month_days(7, 2026)
  66. assert (7, 2026) not in client._had_bookings
  67. async def test_stale_cache_forces_refetch(self):
  68. """Cache shows 0 booked but _had_bookings marks month → refetch triggered."""
  69. client = _airbnb_client()
  70. key = (7, 2026)
  71. client._had_bookings.add(key)
  72. client._raw_months[key] = _days(booked=False) # stale: no bookings
  73. calls = []
  74. async def _fetch(m, y):
  75. calls.append((m, y))
  76. return _days(booked=True)
  77. client._fetch_month_days = _fetch
  78. result = await client._get_month_days(7, 2026)
  79. assert calls == [(7, 2026)], "expected exactly one refetch"
  80. assert any(d.get('reservationData') for d in result)
  81. async def test_no_refetch_without_prior_bookings(self):
  82. """Cache has 0 booked and _had_bookings has no entry → served from cache."""
  83. client = _airbnb_client()
  84. key = (7, 2026)
  85. client._raw_months[key] = _days(booked=False)
  86. calls = []
  87. async def _fetch(m, y):
  88. calls.append((m, y))
  89. return _days(booked=True)
  90. client._fetch_month_days = _fetch
  91. await client._get_month_days(7, 2026)
  92. assert calls == [], "cache hit should not trigger refetch"
  93. async def test_forced_refetch_confirms_empty_removes_mark(self):
  94. """Forced refetch still shows 0 booked → month removed from _had_bookings."""
  95. client = _airbnb_client()
  96. key = (7, 2026)
  97. client._had_bookings.add(key)
  98. client._raw_months[key] = _days(booked=False)
  99. async def _fetch(m, y): return _days(booked=False) # still 0 on refetch
  100. client._fetch_month_days = _fetch
  101. await client._get_month_days(7, 2026)
  102. assert key not in client._had_bookings, "confirmed empty — guard should be lifted"
  103. async def test_refetch_invalidates_fetched_months_and_cache(self):
  104. """Forcing a refetch must also clear _fetched_months and _cache for that month."""
  105. from room_cal.service_api.types import DayInfo
  106. _dummy = DayInfo(date='2026-07-10', price=100, available=True, booked=False, rooms_to_sell=None)
  107. client = _airbnb_client()
  108. key = (7, 2026)
  109. client._had_bookings.add(key)
  110. client._raw_months[key] = _days(booked=False)
  111. client._fetched_months.add(key)
  112. client._cache['2026-07-10'] = _dummy
  113. client._cache['2026-08-01'] = _dummy # different month — must survive
  114. async def _fetch(m, y): return _days(booked=True)
  115. client._fetch_month_days = _fetch
  116. await client._get_month_days(7, 2026)
  117. assert key not in client._fetched_months
  118. assert '2026-07-10' not in client._cache
  119. assert '2026-08-01' in client._cache, "different month must not be evicted"
  120. async def test_had_bookings_survives_clear_cache(self):
  121. """_had_bookings must not be cleared by clear_cache() — that's the whole point."""
  122. client = _airbnb_client()
  123. client._had_bookings.add((7, 2026))
  124. client.clear_cache()
  125. assert (7, 2026) in client._had_bookings
  126. # ── fetch_reservations per-service guard integration tests ────────────────────
  127. class TestFetchReservationsGuard:
  128. """Integration tests: real Reservation store, stub API."""
  129. async def test_errored_service_skips_deletion(self):
  130. """When a service throws (e.g. 502), its stored reservations are preserved."""
  131. rid = 'TEST-GUARD-ERROR-001'
  132. await _seed('airbnb', rid)
  133. try:
  134. await fetch_reservations(_CHECKIN, _CHECKOUT, api=_ErrorAPI('airbnb'))
  135. assert await _exists(rid), "reservation must survive when its service errored"
  136. finally:
  137. await _delete(rid)
  138. async def test_silent_zero_return_skips_deletion(self):
  139. """When a service returns [] with stored upcoming reservations, those are preserved."""
  140. rid = 'TEST-GUARD-ZERO-001'
  141. await _seed('airbnb', rid)
  142. try:
  143. await fetch_reservations(_CHECKIN, _CHECKOUT, api=_BaseAPI())
  144. assert await _exists(rid), "reservation must survive when service silently returned 0"
  145. finally:
  146. await _delete(rid)
  147. async def test_no_guard_when_store_is_empty(self):
  148. """Guard does not fire when there are no stored reservations to protect."""
  149. # Nothing seeded — fetch with empty API should succeed without error.
  150. result = await fetch_reservations(_CHECKIN, _CHECKOUT, api=_BaseAPI())
  151. assert result.data == []