| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- """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
- 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 []
- 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)
- _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)
- 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:
- """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 == []
- # ── 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)
|