| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483 |
- """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
|