Explorar o código

Add reservation deletion guards and tests

- fetch_reservations: skip deletions for errored or silent-zero services
- fetch_reservations: catch ValueError in UserCal block (misconfigured CalDAV)
- test_reservation_guards: 10 tests covering _had_bookings and per-service guard
- test_action_chains: update _MockAPI.get_reservations signature for _out_errors
ZiB hai 4 meses
pai
achega
1bdfaa9601

+ 2 - 1
room_cal/actions/fetch_reservations.py

@@ -183,7 +183,8 @@ async def fetch_reservations(
       user_cal.set(r['service'], r['id'], r['start_date'], r['end_date'])
       for r in service_reservations
     ])
-  except ImportError:
+  except (ImportError, ValueError):
+    # ImportError: user_cal module absent; ValueError: user_cal.url not configured.
     pass
 
   return FetchResult(data=synced, changed=changed)

+ 1 - 1
tests/test_action_chains.py

@@ -26,7 +26,7 @@ class _MockAPI:
   async def get_available(self, d): return True
   async def set_available(self, d, available): pass
   async def get_closed_by_date(self, start, end): return {}
-  async def get_reservations(self, start, end): return []
+  async def get_reservations(self, start, end, *, _out_errors=None): return []
 
 
 class _RecordingAPI(_MockAPI):

+ 200 - 0
tests/test_reservation_guards.py

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