|
|
@@ -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)
|