|
|
@@ -0,0 +1,392 @@
|
|
|
+"""Conformance suite for service_api clients.
|
|
|
+
|
|
|
+This is the executable definition of "done" referenced by CODE_actions.md's
|
|
|
+"Adding a New Service Client" guide: a client that passes this suite and
|
|
|
+implements its own parsing is wired correctly onto ServiceClientBase and
|
|
|
+MonthCache. It complements test_service_base.py (which tests the base layer
|
|
|
+in isolation with a fake client) by exercising each *real* client's own
|
|
|
+request-building code — the part a new client team actually writes.
|
|
|
+
|
|
|
+Parametrized over CLIENT_ADAPTERS. Each adapter wires one client class to:
|
|
|
+ - a fresh instance with self._http backed by an httpx.MockTransport whose
|
|
|
+ queued responses/exceptions this suite controls,
|
|
|
+ - one "raw" request method that goes through self._request exactly once,
|
|
|
+ - the piece of outgoing-request state that must be rebuilt on a post-refresh
|
|
|
+ retry (the historical _msg_gql `ses` bug — see CODE_service_api.md),
|
|
|
+ - calendar-cache glue: a non-suspect and a suspect month-cache payload, the
|
|
|
+ client's own suspect predicate, and how to seed a cached DayInfo so
|
|
|
+ set_price/set_availability become no-ops.
|
|
|
+
|
|
|
+To add a new client to this suite: write one adapter factory below and add
|
|
|
+it to CLIENT_ADAPTERS. Deep response-parsing correctness stays in each
|
|
|
+client's own tests (e.g. test_reservation_guards.py) — this suite only
|
|
|
+asserts the cross-client contract.
|
|
|
+"""
|
|
|
+import asyncio
|
|
|
+import logging
|
|
|
+import time
|
|
|
+from collections.abc import Awaitable, Callable
|
|
|
+from dataclasses import dataclass
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+import httpx
|
|
|
+import pytest
|
|
|
+
|
|
|
+from room_cal.service_api._month_cache import MonthCache
|
|
|
+from room_cal.service_api._utils import CACHE_TTL
|
|
|
+from room_cal.service_api.credentials import clear_stale, is_stale, mark_stale
|
|
|
+from room_cal.service_api.types import DayInfo, ServiceAuthError
|
|
|
+
|
|
|
+_EXPIRED = -(CACHE_TTL + 1)
|
|
|
+
|
|
|
+
|
|
|
+def _mock_transport(responses: list) -> tuple[httpx.MockTransport, list[httpx.Request]]:
|
|
|
+ """MockTransport that serves `responses` in order and records every request.
|
|
|
+
|
|
|
+ An item may be an httpx.Response or an exception instance (raised instead).
|
|
|
+ A call past the end of `responses` fails the test loudly — it means the
|
|
|
+ client under test made a request nothing expected.
|
|
|
+ """
|
|
|
+ it = iter(responses)
|
|
|
+ captured: list[httpx.Request] = []
|
|
|
+
|
|
|
+ def handler(request: httpx.Request) -> httpx.Response:
|
|
|
+ captured.append(request)
|
|
|
+ try:
|
|
|
+ item = next(it)
|
|
|
+ except StopIteration:
|
|
|
+ raise AssertionError(f'unexpected HTTP call: {request.method} {request.url}') from None
|
|
|
+ if isinstance(item, BaseException):
|
|
|
+ raise item
|
|
|
+ return item
|
|
|
+
|
|
|
+ return httpx.MockTransport(handler), captured
|
|
|
+
|
|
|
+
|
|
|
+def _seed(cache: MonthCache, key: tuple[int, int], value, age: float = 0.0) -> None:
|
|
|
+ cache._data[key] = value
|
|
|
+ cache._fetched_at[key] = time.monotonic() + age
|
|
|
+
|
|
|
+
|
|
|
+@dataclass
|
|
|
+class ClientAdapter:
|
|
|
+ name: str # service_key
|
|
|
+ auth_error_cls: type[ServiceAuthError]
|
|
|
+ make_client: Callable[[list], tuple[Any, list[httpx.Request]]]
|
|
|
+ raw_request: Callable[[Any], Awaitable[Any]] # one call through self._request
|
|
|
+ state_marker: Callable[[httpx.Request], str] # value that must change after refresh
|
|
|
+ mutate_state: Callable[[Any], None] # simulates what a real refresh changes
|
|
|
+ ok_calendar_result: Any # non-suspect MonthCache payload
|
|
|
+ suspect_calendar_result: Any # suspect (empty/priceless) payload
|
|
|
+ suspect_predicate: Callable[[Any], Callable[[tuple[int, int], Any], bool]]
|
|
|
+ sample_month_key: tuple[int, int]
|
|
|
+ seed_no_op: Callable[[Any], tuple[str, int, bool]] # client -> (date, price, available)
|
|
|
+
|
|
|
+
|
|
|
+# ── Airbnb adapter ───────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+def _airbnb_adapter() -> ClientAdapter:
|
|
|
+ from room_cal.service_api._airbnb import AirbnbAuthError, _AirbnbClient
|
|
|
+
|
|
|
+ def make_client(responses):
|
|
|
+ client = _AirbnbClient(cookies='cookie-1', listing_id='12345678')
|
|
|
+ transport, captured = _mock_transport(responses)
|
|
|
+ client._http = httpx.AsyncClient(transport=transport, timeout=10.0)
|
|
|
+ return client, captured
|
|
|
+
|
|
|
+ async def raw_request(client):
|
|
|
+ return await client._gql(
|
|
|
+ 'getDLSHostCalendar', 'deadbeef', {}, 'https://www.airbnb.com/multicalendar/12345678')
|
|
|
+
|
|
|
+ def state_marker(request):
|
|
|
+ return request.headers.get('cookie', '')
|
|
|
+
|
|
|
+ def mutate_state(client):
|
|
|
+ client._cookies = 'cookie-2'
|
|
|
+
|
|
|
+ def _day(price):
|
|
|
+ return {'day': '2026-07-10', 'priceData': {'price': price}, 'available': True}
|
|
|
+
|
|
|
+ def seed_no_op(client):
|
|
|
+ d = '2026-07-10'
|
|
|
+ client._cache[d] = DayInfo(date=d, price=150, available=True, booked=False)
|
|
|
+ return d, 150, True
|
|
|
+
|
|
|
+ return ClientAdapter(
|
|
|
+ name='airbnb',
|
|
|
+ auth_error_cls=AirbnbAuthError,
|
|
|
+ make_client=make_client,
|
|
|
+ raw_request=raw_request,
|
|
|
+ state_marker=state_marker,
|
|
|
+ mutate_state=mutate_state,
|
|
|
+ ok_calendar_result=[_day(100)],
|
|
|
+ suspect_calendar_result=[_day(None)],
|
|
|
+ suspect_predicate=lambda client: client._suspect_days,
|
|
|
+ sample_month_key=(12, 2099),
|
|
|
+ seed_no_op=seed_no_op,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+# ── Booking.com adapter ──────────────────────────────────────────────────────
|
|
|
+
|
|
|
+def _booking_adapter() -> ClientAdapter:
|
|
|
+ from room_cal.service_api._booking import _BASE, BookingAuthError, _BookingClient
|
|
|
+
|
|
|
+ def make_client(responses):
|
|
|
+ client = _BookingClient(cookies='cookie', session='ses-1', hotel_id=1,
|
|
|
+ account_id=1, room_id=1, rate_id='r1')
|
|
|
+ transport, captured = _mock_transport(responses)
|
|
|
+ client._http = httpx.AsyncClient(transport=transport, timeout=10.0)
|
|
|
+ return client, captured
|
|
|
+
|
|
|
+ async def raw_request(client):
|
|
|
+ return await client._multipart_post(
|
|
|
+ f'{_BASE}/fresa/extranet/inventory/fetch', client._monthly_referer(), {})
|
|
|
+
|
|
|
+ def state_marker(request):
|
|
|
+ return request.url.params.get('ses', '')
|
|
|
+
|
|
|
+ def mutate_state(client):
|
|
|
+ client._session = 'ses-2'
|
|
|
+
|
|
|
+ def _day(price):
|
|
|
+ return DayInfo(date='2026-07-10', price=price, available=True, booked=False)
|
|
|
+
|
|
|
+ def seed_no_op(client):
|
|
|
+ d = '2026-07-10'
|
|
|
+ client._cache[d] = DayInfo(date=d, price=150, available=True, booked=False)
|
|
|
+ client._rts[d] = 1 # expected rts for available=True, not booked
|
|
|
+ return d, 150, True
|
|
|
+
|
|
|
+ return ClientAdapter(
|
|
|
+ name='booking',
|
|
|
+ auth_error_cls=BookingAuthError,
|
|
|
+ make_client=make_client,
|
|
|
+ raw_request=raw_request,
|
|
|
+ state_marker=state_marker,
|
|
|
+ mutate_state=mutate_state,
|
|
|
+ ok_calendar_result={'2026-07-10': _day(100)},
|
|
|
+ suspect_calendar_result={'2026-07-10': _day(None)},
|
|
|
+ suspect_predicate=lambda client: client._suspect_month,
|
|
|
+ sample_month_key=(12, 2099),
|
|
|
+ seed_no_op=seed_no_op,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+CLIENT_ADAPTERS = [_airbnb_adapter, _booking_adapter]
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture(params=CLIENT_ADAPTERS, ids=['airbnb', 'booking'])
|
|
|
+def adapter(request):
|
|
|
+ a = request.param()
|
|
|
+ clear_stale(a.name)
|
|
|
+ yield a
|
|
|
+ clear_stale(a.name)
|
|
|
+
|
|
|
+
|
|
|
+# ── Registry identity ─────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+def test_service_key_matches_adapter(adapter):
|
|
|
+ client, _ = adapter.make_client([])
|
|
|
+ assert client.service_key == adapter.name
|
|
|
+
|
|
|
+
|
|
|
+# ── Stale fast-fail ────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+_STALE_CALLS = [
|
|
|
+ ('get_calendar', ('2026-07-01', '2026-07-01')),
|
|
|
+ ('get_reservations', ('2026-07-01', '2026-07-01')),
|
|
|
+ ('set_price', ('2026-07-01', 100)),
|
|
|
+ ('set_availability', ('2026-07-01', True)),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+class TestStaleFastFail:
|
|
|
+
|
|
|
+ @pytest.mark.parametrize('method,args', _STALE_CALLS)
|
|
|
+ async def test_fast_fails_without_network_call(self, adapter, method, args):
|
|
|
+ client, captured = adapter.make_client([])
|
|
|
+ mark_stale(adapter.name)
|
|
|
+ with pytest.raises(adapter.auth_error_cls, match='stale'):
|
|
|
+ await getattr(client, method)(*args)
|
|
|
+ assert captured == []
|
|
|
+
|
|
|
+
|
|
|
+# ── Error classification and refresh-retry (via each client's own raw request) ─
|
|
|
+
|
|
|
+class TestRequestSkeleton:
|
|
|
+
|
|
|
+ async def test_timeout_is_not_auth_error(self, adapter):
|
|
|
+ client, _ = adapter.make_client([httpx.ReadTimeout('slow')])
|
|
|
+ with pytest.raises(httpx.TimeoutException):
|
|
|
+ await adapter.raw_request(client)
|
|
|
+ assert not is_stale(adapter.name)
|
|
|
+
|
|
|
+ async def test_single_401_then_success_recovers(self, adapter):
|
|
|
+ client, captured = adapter.make_client([httpx.Response(401), httpx.Response(200, json={})])
|
|
|
+ refreshed = []
|
|
|
+
|
|
|
+ async def fake_refresh():
|
|
|
+ adapter.mutate_state(client)
|
|
|
+ refreshed.append(True)
|
|
|
+
|
|
|
+ client._auto_refresh = fake_refresh
|
|
|
+ await adapter.raw_request(client)
|
|
|
+ assert refreshed == [True]
|
|
|
+ assert len(captured) == 2
|
|
|
+
|
|
|
+ async def test_second_401_marks_stale_and_raises(self, adapter):
|
|
|
+ client, _ = adapter.make_client([httpx.Response(401), httpx.Response(401)])
|
|
|
+
|
|
|
+ async def fake_refresh():
|
|
|
+ pass
|
|
|
+
|
|
|
+ client._auto_refresh = fake_refresh
|
|
|
+ with pytest.raises(adapter.auth_error_cls, match='session expired'):
|
|
|
+ await adapter.raw_request(client)
|
|
|
+ assert is_stale(adapter.name)
|
|
|
+
|
|
|
+ async def test_retry_rebuilds_request_state(self, adapter):
|
|
|
+ """The ses lesson: a retry after refresh must reflect the reloaded state,
|
|
|
+ not a value captured before _ensure_refreshed() ran."""
|
|
|
+ client, captured = adapter.make_client([httpx.Response(401), httpx.Response(200, json={})])
|
|
|
+
|
|
|
+ async def fake_refresh():
|
|
|
+ adapter.mutate_state(client)
|
|
|
+
|
|
|
+ client._auto_refresh = fake_refresh
|
|
|
+ await adapter.raw_request(client)
|
|
|
+ assert len(captured) == 2
|
|
|
+ before, after = (adapter.state_marker(r) for r in captured)
|
|
|
+ assert before != after
|
|
|
+
|
|
|
+
|
|
|
+# ── Month cache wiring ────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+class TestMonthCache:
|
|
|
+
|
|
|
+ async def test_fresh_served_from_cache(self, adapter):
|
|
|
+ client, _ = adapter.make_client([])
|
|
|
+ _seed(client._months, adapter.sample_month_key, adapter.ok_calendar_result)
|
|
|
+ calls = []
|
|
|
+
|
|
|
+ async def fetch(m, y):
|
|
|
+ calls.append((m, y))
|
|
|
+ return adapter.ok_calendar_result
|
|
|
+
|
|
|
+ client._months._fetch = fetch
|
|
|
+ await client._months.get(*adapter.sample_month_key)
|
|
|
+ assert calls == []
|
|
|
+
|
|
|
+ async def test_expired_refetches(self, adapter):
|
|
|
+ client, _ = adapter.make_client([])
|
|
|
+ _seed(client._months, adapter.sample_month_key, adapter.ok_calendar_result, age=_EXPIRED)
|
|
|
+ calls = []
|
|
|
+
|
|
|
+ async def fetch(m, y):
|
|
|
+ calls.append((m, y))
|
|
|
+ return adapter.ok_calendar_result
|
|
|
+
|
|
|
+ client._months._fetch = fetch
|
|
|
+ await client._months.get(*adapter.sample_month_key)
|
|
|
+ assert calls == [adapter.sample_month_key]
|
|
|
+
|
|
|
+ async def test_inflight_dedup(self, adapter):
|
|
|
+ client, _ = adapter.make_client([])
|
|
|
+ calls = []
|
|
|
+
|
|
|
+ async def fetch(m, y):
|
|
|
+ calls.append(1)
|
|
|
+ await asyncio.sleep(0.01)
|
|
|
+ return adapter.ok_calendar_result
|
|
|
+
|
|
|
+ client._months._fetch = fetch
|
|
|
+ key = adapter.sample_month_key
|
|
|
+ await asyncio.gather(client._months.get(*key), client._months.get(*key))
|
|
|
+ assert calls == [1]
|
|
|
+
|
|
|
+ async def test_missing_timestamp_treated_as_expired(self, adapter):
|
|
|
+ client, _ = adapter.make_client([])
|
|
|
+ client._months._data[adapter.sample_month_key] = adapter.ok_calendar_result
|
|
|
+ calls = []
|
|
|
+
|
|
|
+ async def fetch(m, y):
|
|
|
+ calls.append((m, y))
|
|
|
+ return adapter.ok_calendar_result
|
|
|
+
|
|
|
+ client._months._fetch = fetch
|
|
|
+ await client._months.get(*adapter.sample_month_key)
|
|
|
+ assert calls == [adapter.sample_month_key]
|
|
|
+
|
|
|
+ async def test_expired_evicts_derived_dayinfo_cache(self, adapter):
|
|
|
+ """on_evict wiring: the per-date DayInfo cache for the evicted month is
|
|
|
+ cleared, other months survive (both clients' _evict_derived)."""
|
|
|
+ client, _ = adapter.make_client([])
|
|
|
+ key = adapter.sample_month_key
|
|
|
+ month, year = key
|
|
|
+ _seed(client._months, key, adapter.ok_calendar_result, age=_EXPIRED)
|
|
|
+ d_in_month = f'{year}-{month:02d}-10'
|
|
|
+ d_other_month = '2000-01-01'
|
|
|
+ client._cache[d_in_month] = DayInfo(date=d_in_month, price=100, available=True, booked=False)
|
|
|
+ client._cache[d_other_month] = DayInfo(date=d_other_month, price=100, available=True, booked=False)
|
|
|
+
|
|
|
+ async def fetch(m, y):
|
|
|
+ return adapter.ok_calendar_result
|
|
|
+
|
|
|
+ client._months._fetch = fetch
|
|
|
+ await client._months.get(*key)
|
|
|
+ assert d_in_month not in client._cache
|
|
|
+ assert d_other_month in client._cache
|
|
|
+
|
|
|
+ def test_clear_cache_empties_month_cache(self, adapter):
|
|
|
+ client, _ = adapter.make_client([])
|
|
|
+ key = adapter.sample_month_key
|
|
|
+ client._months._data[key] = adapter.ok_calendar_result
|
|
|
+ client._months._fetched_at[key] = time.monotonic()
|
|
|
+ client.clear_cache()
|
|
|
+ assert client._months._data == {}
|
|
|
+
|
|
|
+
|
|
|
+# ── Empty/priceless (suspect) retry ───────────────────────────────────────────
|
|
|
+
|
|
|
+class TestSuspectRetry:
|
|
|
+
|
|
|
+ def test_suspect_predicate_flags_priceless(self, adapter):
|
|
|
+ client, _ = adapter.make_client([])
|
|
|
+ pred = adapter.suspect_predicate(client)
|
|
|
+ assert pred(adapter.sample_month_key, adapter.suspect_calendar_result) is True
|
|
|
+ assert pred(adapter.sample_month_key, adapter.ok_calendar_result) is False
|
|
|
+
|
|
|
+ async def test_retries_once_then_accepts(self, adapter, caplog):
|
|
|
+ client, _ = adapter.make_client([])
|
|
|
+ results = [adapter.suspect_calendar_result, adapter.ok_calendar_result]
|
|
|
+ calls = []
|
|
|
+
|
|
|
+ async def fetch(m, y):
|
|
|
+ calls.append(1)
|
|
|
+ return results.pop(0)
|
|
|
+
|
|
|
+ cache = MonthCache(fetch, suspect=adapter.suspect_predicate(client),
|
|
|
+ label=adapter.name, log=logging.getLogger('test.conformance'), retry_delay=0)
|
|
|
+ with caplog.at_level(logging.WARNING):
|
|
|
+ result = await cache.get(*adapter.sample_month_key)
|
|
|
+ assert calls == [1, 1]
|
|
|
+ assert result == adapter.ok_calendar_result
|
|
|
+ assert any('retrying' in r.message for r in caplog.records)
|
|
|
+
|
|
|
+
|
|
|
+# ── No-op writes when cached state already matches ────────────────────────────
|
|
|
+
|
|
|
+class TestNoOpWrites:
|
|
|
+
|
|
|
+ async def test_set_price_no_op_when_cached_matches(self, adapter):
|
|
|
+ client, captured = adapter.make_client([])
|
|
|
+ d, price, _available = adapter.seed_no_op(client)
|
|
|
+ await client.set_price(d, price)
|
|
|
+ assert captured == []
|
|
|
+
|
|
|
+ async def test_set_availability_no_op_when_cached_matches(self, adapter):
|
|
|
+ client, captured = adapter.make_client([])
|
|
|
+ d, _price, available = adapter.seed_no_op(client)
|
|
|
+ await client.set_availability(d, available)
|
|
|
+ assert captured == []
|