"""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 == [] # ── Airbnb body-shaped auth (GQL errors on HTTP 200) ────────────────────────── _GQL_AUTH_BODY = {'errors': [{'extensions': {'response': {'status': 401}}}]} class TestAirbnbBodyAuth: """Airbnb signals session expiry as GQL error extensions on HTTP 200; the _auth_shaped override must route these through the same refresh → retry-once → stale sequence as status-shaped failures.""" async def test_body_auth_refreshes_and_retries_once(self): adapter = _airbnb_adapter() clear_stale(adapter.name) client, captured = adapter.make_client([ httpx.Response(200, json=_GQL_AUTH_BODY), httpx.Response(200, json={'data': {}}), ]) refreshed = [] async def fake_refresh(): adapter.mutate_state(client) refreshed.append(True) client._auto_refresh = fake_refresh body = await adapter.raw_request(client) assert body == {'data': {}} assert refreshed == [True] assert len(captured) == 2 assert not is_stale(adapter.name) async def test_body_auth_twice_marks_stale_and_raises(self): adapter = _airbnb_adapter() clear_stale(adapter.name) client, _ = adapter.make_client([ httpx.Response(200, json=_GQL_AUTH_BODY), httpx.Response(200, json=_GQL_AUTH_BODY), ]) 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) clear_stale(adapter.name) async def test_non_auth_gql_errors_pass_through(self): adapter = _airbnb_adapter() clear_stale(adapter.name) body = {'errors': [{'message': 'validation failed'}], 'data': {}} client, captured = adapter.make_client([httpx.Response(200, json=body)]) result = await adapter.raw_request(client) assert result == body assert len(captured) == 1 assert not is_stale(adapter.name) # ── Base get_calendar skeleton ──────────────────────────────────────────────── class TestGetCalendarSkeleton: """The base owns get_calendar: parse each month payload once via the client's _month_day_infos hook, serve repeats from the per-date cache, filter to range.""" async def test_populates_serves_and_filters_without_reparse(self, adapter): client, captured = adapter.make_client([]) _seed(client._months, (7, 2026), adapter.ok_calendar_result) parses = [] orig = client._month_day_infos client._month_day_infos = lambda payload: (parses.append(1) or orig(payload)) r1 = await client.get_calendar('2026-07-01', '2026-07-31') r2 = await client.get_calendar('2026-07-01', '2026-07-31') assert '2026-07-10' in r1 assert r1['2026-07-10'].price == 100 assert r1 == r2 assert parses == [1] assert captured == [] # cache-served: no network calls assert await client.get_calendar('2026-07-11', '2026-07-31') == {} # range filter async def test_eviction_reparses(self, adapter): client, _ = adapter.make_client([]) _seed(client._months, (7, 2026), adapter.ok_calendar_result) await client.get_calendar('2026-07-01', '2026-07-31') assert '2026-07-10' in client._cache client._months.invalidate(7, 2026) assert '2026-07-10' not in client._cache assert (7, 2026) not in client._parsed_months