Explorar el Código

Add mass-deletion guard to fetch_reservations

Cancel a service's pending deletions when a single run would remove
more than _MAX_DELETIONS_PER_RUN (2) reservations — real cancellations
arrive at most one or two per polling interval, so a larger batch
indicates partial/bad service data that the silent-zero guard cannot
catch.
ZiB hace 4 meses
padre
commit
069cdc0585
Se han modificado 2 ficheros con 99 adiciones y 1 borrados
  1. 21 0
      room_cal/actions/fetch_reservations.py
  2. 78 1
      tests/test_reservation_guards.py

+ 21 - 0
room_cal/actions/fetch_reservations.py

@@ -14,6 +14,10 @@ _log = logging.getLogger(__name__)
 
 ACTION_SPEC = ('fetch-reservations', [])
 
+# Real cancellations arrive at most one or two per polling interval; a run that
+# would delete more stored reservations than this is treated as bad service data.
+_MAX_DELETIONS_PER_RUN = 2
+
 
 def _source_matches(stored: dict, domain: str, key: str, id_: str) -> bool:
   return (
@@ -97,6 +101,23 @@ async def fetch_reservations(
       )
       guarded_services.add(svc)
 
+  # Mass-deletion guard: cancel a service's deletions when a single run would
+  # remove more than _MAX_DELETIONS_PER_RUN reservations.
+  pending_deletions: dict[str, int] = {}
+  for rid, (_, data) in existing_by_id.items():
+    svc = data.get('service', '')
+    if svc in guarded_services or rid in service_ids:
+      continue
+    pending_deletions[svc] = pending_deletions.get(svc, 0) + 1
+  for svc, count in pending_deletions.items():
+    if count > _MAX_DELETIONS_PER_RUN:
+      _log.error(
+        'fetch_reservations: %r would delete %d reservations in one run (max %d); '
+        'skipping deletion(s) — verify service is healthy',
+        svc, count, _MAX_DELETIONS_PER_RUN,
+      )
+      guarded_services.add(svc)
+
   changed = False
 
   for rid, (event, data) in existing_by_id.items():

+ 78 - 1
tests/test_reservation_guards.py

@@ -1,8 +1,10 @@
-"""Tests for the two reservation-deletion guard mechanisms.
+"""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.
 """
 from datetime import date, timedelta
 
@@ -43,6 +45,23 @@ class _ErrorAPI(_BaseAPI):
     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 _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)
@@ -70,6 +89,20 @@ async def _delete(rid: str) -> None:
     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:
@@ -198,3 +231,47 @@ class TestFetchReservationsGuard:
     # Nothing seeded — fetch with empty API should succeed without error.
     result = await fetch_reservations(_CHECKIN, _CHECKOUT, api=_BaseAPI())
     assert result.data == []
+
+
+# ── 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)