test_service_client_conformance.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. """Conformance suite for service_api clients.
  2. This is the executable definition of "done" referenced by CODE_actions.md's
  3. "Adding a New Service Client" guide: a client that passes this suite and
  4. implements its own parsing is wired correctly onto ServiceClientBase and
  5. MonthCache. It complements test_service_base.py (which tests the base layer
  6. in isolation with a fake client) by exercising each *real* client's own
  7. request-building code — the part a new client team actually writes.
  8. Parametrized over CLIENT_ADAPTERS. Each adapter wires one client class to:
  9. - a fresh instance with self._http backed by an httpx.MockTransport whose
  10. queued responses/exceptions this suite controls,
  11. - one "raw" request method that goes through self._request exactly once,
  12. - the piece of outgoing-request state that must be rebuilt on a post-refresh
  13. retry (the historical _msg_gql `ses` bug — see CODE_service_api.md),
  14. - calendar-cache glue: a non-suspect and a suspect month-cache payload, the
  15. client's own suspect predicate, and how to seed a cached DayInfo so
  16. set_price/set_availability become no-ops.
  17. To add a new client to this suite: write one adapter factory below and add
  18. it to CLIENT_ADAPTERS. Deep response-parsing correctness stays in each
  19. client's own tests (e.g. test_reservation_guards.py) — this suite only
  20. asserts the cross-client contract.
  21. """
  22. import asyncio
  23. import logging
  24. import time
  25. from collections.abc import Awaitable, Callable
  26. from dataclasses import dataclass
  27. from typing import Any
  28. import httpx
  29. import pytest
  30. from room_cal.service_api._month_cache import MonthCache
  31. from room_cal.service_api._utils import CACHE_TTL
  32. from room_cal.service_api.credentials import clear_stale, is_stale, mark_stale
  33. from room_cal.service_api.types import DayInfo, ServiceAuthError
  34. _EXPIRED = -(CACHE_TTL + 1)
  35. def _mock_transport(responses: list) -> tuple[httpx.MockTransport, list[httpx.Request]]:
  36. """MockTransport that serves `responses` in order and records every request.
  37. An item may be an httpx.Response or an exception instance (raised instead).
  38. A call past the end of `responses` fails the test loudly — it means the
  39. client under test made a request nothing expected.
  40. """
  41. it = iter(responses)
  42. captured: list[httpx.Request] = []
  43. def handler(request: httpx.Request) -> httpx.Response:
  44. captured.append(request)
  45. try:
  46. item = next(it)
  47. except StopIteration:
  48. raise AssertionError(f'unexpected HTTP call: {request.method} {request.url}') from None
  49. if isinstance(item, BaseException):
  50. raise item
  51. return item
  52. return httpx.MockTransport(handler), captured
  53. def _seed(cache: MonthCache, key: tuple[int, int], value, age: float = 0.0) -> None:
  54. cache._data[key] = value
  55. cache._fetched_at[key] = time.monotonic() + age
  56. @dataclass
  57. class ClientAdapter:
  58. name: str # service_key
  59. auth_error_cls: type[ServiceAuthError]
  60. make_client: Callable[[list], tuple[Any, list[httpx.Request]]]
  61. raw_request: Callable[[Any], Awaitable[Any]] # one call through self._request
  62. state_marker: Callable[[httpx.Request], str] # value that must change after refresh
  63. mutate_state: Callable[[Any], None] # simulates what a real refresh changes
  64. ok_calendar_result: Any # non-suspect MonthCache payload
  65. suspect_calendar_result: Any # suspect (empty/priceless) payload
  66. suspect_predicate: Callable[[Any], Callable[[tuple[int, int], Any], bool]]
  67. sample_month_key: tuple[int, int]
  68. seed_no_op: Callable[[Any], tuple[str, int, bool]] # client -> (date, price, available)
  69. # ── Airbnb adapter ───────────────────────────────────────────────────────────
  70. def _airbnb_adapter() -> ClientAdapter:
  71. from room_cal.service_api._airbnb import AirbnbAuthError, _AirbnbClient
  72. def make_client(responses):
  73. client = _AirbnbClient(cookies='cookie-1', listing_id='12345678')
  74. transport, captured = _mock_transport(responses)
  75. client._http = httpx.AsyncClient(transport=transport, timeout=10.0)
  76. return client, captured
  77. async def raw_request(client):
  78. return await client._gql(
  79. 'getDLSHostCalendar', 'deadbeef', {}, 'https://www.airbnb.com/multicalendar/12345678')
  80. def state_marker(request):
  81. return request.headers.get('cookie', '')
  82. def mutate_state(client):
  83. client._cookies = 'cookie-2'
  84. def _day(price):
  85. return {'day': '2026-07-10', 'priceData': {'price': price}, 'available': True}
  86. def seed_no_op(client):
  87. d = '2026-07-10'
  88. client._cache[d] = DayInfo(date=d, price=150, available=True, booked=False)
  89. return d, 150, True
  90. return ClientAdapter(
  91. name='airbnb',
  92. auth_error_cls=AirbnbAuthError,
  93. make_client=make_client,
  94. raw_request=raw_request,
  95. state_marker=state_marker,
  96. mutate_state=mutate_state,
  97. ok_calendar_result=[_day(100)],
  98. suspect_calendar_result=[_day(None)],
  99. suspect_predicate=lambda client: client._suspect_days,
  100. sample_month_key=(12, 2099),
  101. seed_no_op=seed_no_op,
  102. )
  103. # ── Booking.com adapter ──────────────────────────────────────────────────────
  104. def _booking_adapter() -> ClientAdapter:
  105. from room_cal.service_api._booking import _BASE, BookingAuthError, _BookingClient
  106. def make_client(responses):
  107. client = _BookingClient(cookies='cookie', session='ses-1', hotel_id=1,
  108. account_id=1, room_id=1, rate_id='r1')
  109. transport, captured = _mock_transport(responses)
  110. client._http = httpx.AsyncClient(transport=transport, timeout=10.0)
  111. return client, captured
  112. async def raw_request(client):
  113. return await client._multipart_post(
  114. f'{_BASE}/fresa/extranet/inventory/fetch', client._monthly_referer(), {})
  115. def state_marker(request):
  116. return request.url.params.get('ses', '')
  117. def mutate_state(client):
  118. client._session = 'ses-2'
  119. def _day(price):
  120. return DayInfo(date='2026-07-10', price=price, available=True, booked=False)
  121. def seed_no_op(client):
  122. d = '2026-07-10'
  123. client._cache[d] = DayInfo(date=d, price=150, available=True, booked=False)
  124. client._rts[d] = 1 # expected rts for available=True, not booked
  125. return d, 150, True
  126. return ClientAdapter(
  127. name='booking',
  128. auth_error_cls=BookingAuthError,
  129. make_client=make_client,
  130. raw_request=raw_request,
  131. state_marker=state_marker,
  132. mutate_state=mutate_state,
  133. ok_calendar_result={'2026-07-10': _day(100)},
  134. suspect_calendar_result={'2026-07-10': _day(None)},
  135. suspect_predicate=lambda client: client._suspect_month,
  136. sample_month_key=(12, 2099),
  137. seed_no_op=seed_no_op,
  138. )
  139. CLIENT_ADAPTERS = [_airbnb_adapter, _booking_adapter]
  140. @pytest.fixture(params=CLIENT_ADAPTERS, ids=['airbnb', 'booking'])
  141. def adapter(request):
  142. a = request.param()
  143. clear_stale(a.name)
  144. yield a
  145. clear_stale(a.name)
  146. # ── Registry identity ─────────────────────────────────────────────────────────
  147. def test_service_key_matches_adapter(adapter):
  148. client, _ = adapter.make_client([])
  149. assert client.service_key == adapter.name
  150. # ── Stale fast-fail ────────────────────────────────────────────────────────────
  151. _STALE_CALLS = [
  152. ('get_calendar', ('2026-07-01', '2026-07-01')),
  153. ('get_reservations', ('2026-07-01', '2026-07-01')),
  154. ('set_price', ('2026-07-01', 100)),
  155. ('set_availability', ('2026-07-01', True)),
  156. ]
  157. class TestStaleFastFail:
  158. @pytest.mark.parametrize('method,args', _STALE_CALLS)
  159. async def test_fast_fails_without_network_call(self, adapter, method, args):
  160. client, captured = adapter.make_client([])
  161. mark_stale(adapter.name)
  162. with pytest.raises(adapter.auth_error_cls, match='stale'):
  163. await getattr(client, method)(*args)
  164. assert captured == []
  165. # ── Error classification and refresh-retry (via each client's own raw request) ─
  166. class TestRequestSkeleton:
  167. async def test_timeout_is_not_auth_error(self, adapter):
  168. client, _ = adapter.make_client([httpx.ReadTimeout('slow')])
  169. with pytest.raises(httpx.TimeoutException):
  170. await adapter.raw_request(client)
  171. assert not is_stale(adapter.name)
  172. async def test_single_401_then_success_recovers(self, adapter):
  173. client, captured = adapter.make_client([httpx.Response(401), httpx.Response(200, json={})])
  174. refreshed = []
  175. async def fake_refresh():
  176. adapter.mutate_state(client)
  177. refreshed.append(True)
  178. client._auto_refresh = fake_refresh
  179. await adapter.raw_request(client)
  180. assert refreshed == [True]
  181. assert len(captured) == 2
  182. async def test_second_401_marks_stale_and_raises(self, adapter):
  183. client, _ = adapter.make_client([httpx.Response(401), httpx.Response(401)])
  184. async def fake_refresh():
  185. pass
  186. client._auto_refresh = fake_refresh
  187. with pytest.raises(adapter.auth_error_cls, match='session expired'):
  188. await adapter.raw_request(client)
  189. assert is_stale(adapter.name)
  190. async def test_retry_rebuilds_request_state(self, adapter):
  191. """The ses lesson: a retry after refresh must reflect the reloaded state,
  192. not a value captured before _ensure_refreshed() ran."""
  193. client, captured = adapter.make_client([httpx.Response(401), httpx.Response(200, json={})])
  194. async def fake_refresh():
  195. adapter.mutate_state(client)
  196. client._auto_refresh = fake_refresh
  197. await adapter.raw_request(client)
  198. assert len(captured) == 2
  199. before, after = (adapter.state_marker(r) for r in captured)
  200. assert before != after
  201. # ── Month cache wiring ────────────────────────────────────────────────────────
  202. class TestMonthCache:
  203. async def test_fresh_served_from_cache(self, adapter):
  204. client, _ = adapter.make_client([])
  205. _seed(client._months, adapter.sample_month_key, adapter.ok_calendar_result)
  206. calls = []
  207. async def fetch(m, y):
  208. calls.append((m, y))
  209. return adapter.ok_calendar_result
  210. client._months._fetch = fetch
  211. await client._months.get(*adapter.sample_month_key)
  212. assert calls == []
  213. async def test_expired_refetches(self, adapter):
  214. client, _ = adapter.make_client([])
  215. _seed(client._months, adapter.sample_month_key, adapter.ok_calendar_result, age=_EXPIRED)
  216. calls = []
  217. async def fetch(m, y):
  218. calls.append((m, y))
  219. return adapter.ok_calendar_result
  220. client._months._fetch = fetch
  221. await client._months.get(*adapter.sample_month_key)
  222. assert calls == [adapter.sample_month_key]
  223. async def test_inflight_dedup(self, adapter):
  224. client, _ = adapter.make_client([])
  225. calls = []
  226. async def fetch(m, y):
  227. calls.append(1)
  228. await asyncio.sleep(0.01)
  229. return adapter.ok_calendar_result
  230. client._months._fetch = fetch
  231. key = adapter.sample_month_key
  232. await asyncio.gather(client._months.get(*key), client._months.get(*key))
  233. assert calls == [1]
  234. async def test_missing_timestamp_treated_as_expired(self, adapter):
  235. client, _ = adapter.make_client([])
  236. client._months._data[adapter.sample_month_key] = adapter.ok_calendar_result
  237. calls = []
  238. async def fetch(m, y):
  239. calls.append((m, y))
  240. return adapter.ok_calendar_result
  241. client._months._fetch = fetch
  242. await client._months.get(*adapter.sample_month_key)
  243. assert calls == [adapter.sample_month_key]
  244. async def test_expired_evicts_derived_dayinfo_cache(self, adapter):
  245. """on_evict wiring: the per-date DayInfo cache for the evicted month is
  246. cleared, other months survive (both clients' _evict_derived)."""
  247. client, _ = adapter.make_client([])
  248. key = adapter.sample_month_key
  249. month, year = key
  250. _seed(client._months, key, adapter.ok_calendar_result, age=_EXPIRED)
  251. d_in_month = f'{year}-{month:02d}-10'
  252. d_other_month = '2000-01-01'
  253. client._cache[d_in_month] = DayInfo(date=d_in_month, price=100, available=True, booked=False)
  254. client._cache[d_other_month] = DayInfo(date=d_other_month, price=100, available=True, booked=False)
  255. async def fetch(m, y):
  256. return adapter.ok_calendar_result
  257. client._months._fetch = fetch
  258. await client._months.get(*key)
  259. assert d_in_month not in client._cache
  260. assert d_other_month in client._cache
  261. def test_clear_cache_empties_month_cache(self, adapter):
  262. client, _ = adapter.make_client([])
  263. key = adapter.sample_month_key
  264. client._months._data[key] = adapter.ok_calendar_result
  265. client._months._fetched_at[key] = time.monotonic()
  266. client.clear_cache()
  267. assert client._months._data == {}
  268. # ── Empty/priceless (suspect) retry ───────────────────────────────────────────
  269. class TestSuspectRetry:
  270. def test_suspect_predicate_flags_priceless(self, adapter):
  271. client, _ = adapter.make_client([])
  272. pred = adapter.suspect_predicate(client)
  273. assert pred(adapter.sample_month_key, adapter.suspect_calendar_result) is True
  274. assert pred(adapter.sample_month_key, adapter.ok_calendar_result) is False
  275. async def test_retries_once_then_accepts(self, adapter, caplog):
  276. client, _ = adapter.make_client([])
  277. results = [adapter.suspect_calendar_result, adapter.ok_calendar_result]
  278. calls = []
  279. async def fetch(m, y):
  280. calls.append(1)
  281. return results.pop(0)
  282. cache = MonthCache(fetch, suspect=adapter.suspect_predicate(client),
  283. label=adapter.name, log=logging.getLogger('test.conformance'), retry_delay=0)
  284. with caplog.at_level(logging.WARNING):
  285. result = await cache.get(*adapter.sample_month_key)
  286. assert calls == [1, 1]
  287. assert result == adapter.ok_calendar_result
  288. assert any('retrying' in r.message for r in caplog.records)
  289. # ── No-op writes when cached state already matches ────────────────────────────
  290. class TestNoOpWrites:
  291. async def test_set_price_no_op_when_cached_matches(self, adapter):
  292. client, captured = adapter.make_client([])
  293. d, price, _available = adapter.seed_no_op(client)
  294. await client.set_price(d, price)
  295. assert captured == []
  296. async def test_set_availability_no_op_when_cached_matches(self, adapter):
  297. client, captured = adapter.make_client([])
  298. d, _price, available = adapter.seed_no_op(client)
  299. await client.set_availability(d, available)
  300. assert captured == []