فهرست منبع

service_api: conformance test suite for new client teams

test_service_client_conformance.py is the executable "definition of
done" from CODE_actions.md: parametrized over an adapter per client
class (currently airbnb/booking), it exercises each client's own
request-building code against an httpx.MockTransport — stale fast-fail,
timeout-never-becomes-auth-error, 401-refresh-retry-once, the ses
lesson (retry must rebuild request state, not reuse a stale capture),
MonthCache wiring (TTL, in-flight dedup, on_evict, clear_cache),
suspect-retry, and no-op writes when cached state already matches.

test_service_cache_ttl.py slims down to just the Airbnb reservation-
detail TTL cache, which has no Booking.com analog and so isn't part
of the generic conformance contract; its month-cache cases moved into
the new suite.
ZiB 3 ماه پیش
والد
کامیت
adacbbad1d
2فایلهای تغییر یافته به همراه400 افزوده شده و 151 حذف شده
  1. 8 151
      tests/test_service_cache_ttl.py
  2. 392 0
      tests/test_service_client_conformance.py

+ 8 - 151
tests/test_service_cache_ttl.py

@@ -1,17 +1,16 @@
-"""Tests for the service-client cache TTL (CACHE_TTL in service_api/_utils.py).
+"""Tests for the Airbnb reservation-detail TTL cache (TTLCache in _month_cache.py).
 
-The month/detail caches exist to dedupe requests within one operation; entries
-older than the TTL must be refetched so upstream changes (e.g. a canceled
-reservation) can't outlive them in a long-running server process.
-
-Cache mechanics (TTL, inflight dedup, suspect retry) are covered directly in
-test_month_cache.py; these tests verify each client's wiring — that the month
-caches are hooked up with eviction of the derived per-date DayInfo cache.
+Month-cache mechanics (TTL, in-flight dedup, suspect retry, on_evict wiring)
+are covered generically across both clients in
+test_service_client_conformance.py; TTLCache mechanics in general are covered
+in test_month_cache.py. This file is what's left that's Airbnb-specific: the
+per-reservation detail cache (self._details) has no analog on Booking.com.
 """
 import time
 
 from room_cal.service_api._utils import CACHE_TTL
-from room_cal.service_api.types import DayInfo
+
+_EXPIRED = -(CACHE_TTL + 1)
 
 
 def _airbnb_client():
@@ -19,100 +18,6 @@ def _airbnb_client():
   return _AirbnbClient(cookies='test', listing_id='12345678')
 
 
-def _booking_client():
-  from room_cal.service_api._booking import _BookingClient
-  return _BookingClient(cookies='test', session='ses', hotel_id=1,
-                        account_id=1, room_id=1, rate_id='r1')
-
-
-def _days(booked=False):
-  day = {'day': '2026-07-10', 'priceData': {'price': 100}, 'available': True}
-  if booked:
-    day['reservationData'] = {'confirmationCode': 'TEST123'}
-  return [day]
-
-
-def _day_info(d='2026-07-10'):
-  return DayInfo(date=d, price=100, available=True, booked=False)
-
-
-_EXPIRED = -(CACHE_TTL + 1)
-
-
-def _seed(cache, key, value, age=0.0):
-  cache._data[key] = value
-  cache._fetched_at[key] = time.monotonic() + age
-
-
-class TestAirbnbMonthTTL:
-
-  async def test_fresh_month_served_from_cache(self):
-    client = _airbnb_client()
-    _seed(client._months, (7, 2026), _days())
-
-    calls = []
-    async def _fetch(m, y):
-      calls.append((m, y))
-      return _days()
-    client._fetch_month_days = _fetch
-
-    await client._months.get(7, 2026)
-    assert calls == []
-
-  async def test_expired_month_refetches(self):
-    client = _airbnb_client()
-    _seed(client._months, (7, 2026), _days(), age=_EXPIRED)
-
-    calls = []
-    async def _fetch(m, y):
-      calls.append((m, y))
-      return _days(booked=True)
-    client._fetch_month_days = _fetch
-
-    result = await client._months.get(7, 2026)
-    assert calls == [(7, 2026)]
-    assert any(d.get('reservationData') for d in result)
-
-  async def test_missing_timestamp_treated_as_expired(self):
-    client = _airbnb_client()
-    client._months._data[(7, 2026)] = _days()
-
-    calls = []
-    async def _fetch(m, y):
-      calls.append((m, y))
-      return _days()
-    client._fetch_month_days = _fetch
-
-    await client._months.get(7, 2026)
-    assert calls == [(7, 2026)]
-
-  async def test_expired_month_evicts_dayinfo_cache(self):
-    client = _airbnb_client()
-    key = (7, 2026)
-    _seed(client._months, key, _days(), age=_EXPIRED)
-    client._fetched_months.add(key)
-    client._cache['2026-07-10'] = _day_info('2026-07-10')
-    client._cache['2026-08-01'] = _day_info('2026-08-01')  # different month — must survive
-
-    async def _fetch(m, y): return _days()
-    client._fetch_month_days = _fetch
-
-    await client._months.get(7, 2026)
-    assert key not in client._fetched_months
-    assert '2026-07-10' not in client._cache
-    assert '2026-08-01' in client._cache
-
-  async def test_refetch_records_fresh_timestamp(self):
-    client = _airbnb_client()
-    key = (7, 2026)
-    async def _fetch(m, y): return _days()
-    client._fetch_month_days = _fetch
-
-    await client._months.get(7, 2026)
-    assert key in client._months._fetched_at
-    assert time.monotonic() - client._months._fetched_at[key] < CACHE_TTL
-
-
 class TestAirbnbDetailTTL:
 
   def _stub_gql(self, client, det):
@@ -135,51 +40,3 @@ class TestAirbnbDetailTTL:
 
     assert await client._fetch_one_detail('C1') == {'fresh': True}
     assert client._details.get('C1') == {'fresh': True}
-
-
-class TestBookingMonthTTL:
-
-  async def test_fresh_month_served_from_cache(self):
-    client = _booking_client()
-    _seed(client._months, (7, 2026), {'2026-07-10': _day_info()})
-
-    calls = []
-    async def _fetch(m, y):
-      calls.append((m, y))
-      return {}
-    client._fetch_month_calendar = _fetch
-
-    await client._months.get(7, 2026)
-    assert calls == []
-
-  async def test_expired_month_refetches_and_evicts_dayinfo_cache(self):
-    client = _booking_client()
-    key = (7, 2026)
-    _seed(client._months, key, {'2026-07-10': _day_info()}, age=_EXPIRED)
-    client._cache['2026-07-10'] = _day_info('2026-07-10')
-    client._cache['2026-08-01'] = _day_info('2026-08-01')  # different month — must survive
-
-    calls = []
-    async def _fetch(m, y):
-      calls.append((m, y))
-      return {'2026-07-10': _day_info()}
-    client._fetch_month_calendar = _fetch
-
-    await client._months.get(7, 2026)
-    assert calls == [(7, 2026)]
-    assert '2026-07-10' not in client._cache
-    assert '2026-08-01' in client._cache
-    assert key in client._months._fetched_at
-
-  async def test_missing_timestamp_treated_as_expired(self):
-    client = _booking_client()
-    client._months._data[(7, 2026)] = {'2026-07-10': _day_info()}
-
-    calls = []
-    async def _fetch(m, y):
-      calls.append((m, y))
-      return {'2026-07-10': _day_info()}  # priced result — must not trip the suspect retry
-    client._fetch_month_calendar = _fetch
-
-    await client._months.get(7, 2026)
-    assert calls == [(7, 2026)]

+ 392 - 0
tests/test_service_client_conformance.py

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