"""Unit tests for service_api._base (ServiceClientBase). Covers the cross-client invariants: stale fast-fail, error classification (timeouts are never auth errors), the attempt → refresh → retry-once request skeleton, and refresh orchestration bookkeeping. """ import asyncio import httpx import pytest from room_cal.service_api._base import ServiceClientBase from room_cal.service_api.credentials import clear_stale, is_stale, mark_stale from room_cal.service_api.types import ServiceAuthError class FakeAuthError(ServiceAuthError): pass class _FakeClient(ServiceClientBase): service_key = 'fake' service_name = 'Fake' auth_error_cls = FakeAuthError def __init__(self): super().__init__() self.env = {'FAKE_TOKEN': 'initial'} self.token = 'initial' self.cleared_cache = 0 @classmethod def _read_env(cls): # avoid the filesystem return _FakeClient._test_env def _apply_env(self, env): self.token = env.get('FAKE_TOKEN', self.token) def clear_cache(self): self.cleared_cache += 1 _FakeClient._test_env = {'FAKE_TOKEN': 'initial'} def _resp(status, body=''): return httpx.Response(status, text=body, request=httpx.Request('GET', 'https://fake.test/x')) @pytest.fixture(autouse=True) def _fresh(): clear_stale('fake') _FakeClient._test_env = {'FAKE_TOKEN': 'initial'} yield clear_stale('fake') class TestCheckFresh: def test_passes_when_fresh(self): _FakeClient()._check_fresh() def test_raises_when_stale(self): mark_stale('fake') with pytest.raises(FakeAuthError, match='credentials are stale'): _FakeClient()._check_fresh() class TestRequest: async def test_success_passthrough(self): client = _FakeClient() r = await client._request(lambda: asyncio.sleep(0, _resp(200, 'ok')), op='x') assert r.status_code == 200 async def test_timeout_is_not_auth_error(self): client = _FakeClient() async def _attempt(): raise httpx.ReadTimeout('slow') with pytest.raises(httpx.TimeoutException): await client._request(_attempt, op='x') assert not is_stale('fake') async def test_transport_error_becomes_auth_error(self): client = _FakeClient() async def _attempt(): raise httpx.ConnectError('refused') with pytest.raises(FakeAuthError, match='connection failed'): await client._request(_attempt, op='x') async def test_auth_status_refreshes_and_retries_once(self): client = _FakeClient() refreshes = [] async def _refresh(): refreshes.append(True) client._auto_refresh = _refresh responses = [_resp(401), _resp(200)] r = await client._request(lambda: asyncio.sleep(0, responses.pop(0)), op='x') assert r.status_code == 200 assert refreshes == [True] async def test_second_auth_status_marks_stale_and_raises(self): client = _FakeClient() async def _refresh(): pass client._auto_refresh = _refresh responses = [_resp(401), _resp(403)] with pytest.raises(FakeAuthError, match='session expired \\(HTTP 403\\)'): await client._request(lambda: asyncio.sleep(0, responses.pop(0)), op='x') assert is_stale('fake') async def test_retry_sees_state_reloaded_by_refresh(self): """The ses lesson: attempt closures must read client state per call.""" client = _FakeClient() seen = [] async def _attempt(): seen.append(client.token) return _resp(401 if len(seen) == 1 else 200) async def _refresh(): client.token = 'refreshed' client._auto_refresh = _refresh await client._request(_attempt, op='x') assert seen == ['initial', 'refreshed'] async def test_body_shaped_auth_refreshes_and_retries_once(self): """A client overriding _auth_shaped gets the same retry as status-shaped auth.""" class _BodyAuthClient(_FakeClient): def _auth_shaped(self, r): return super()._auth_shaped(r) or 'expired' in r.text client = _BodyAuthClient() refreshes = [] async def _refresh(): refreshes.append(True) client._auto_refresh = _refresh responses = [_resp(200, 'expired'), _resp(200, 'ok')] r = await client._request(lambda: asyncio.sleep(0, responses.pop(0)), op='x') assert r.text == 'ok' assert refreshes == [True] async def test_body_shaped_auth_twice_marks_stale_and_raises(self): class _BodyAuthClient(_FakeClient): def _auth_shaped(self, r): return super()._auth_shaped(r) or 'expired' in r.text client = _BodyAuthClient() async def _refresh(): pass client._auto_refresh = _refresh responses = [_resp(200, 'expired'), _resp(200, 'expired')] with pytest.raises(FakeAuthError, match='session expired'): await client._request(lambda: asyncio.sleep(0, responses.pop(0)), op='x') assert is_stale('fake') async def test_http_error_status_raises_plain(self): client = _FakeClient() with pytest.raises(httpx.HTTPStatusError): await client._request(lambda: asyncio.sleep(0, _resp(500)), op='x') assert not is_stale('fake') class TestAutoRefresh: async def test_cheap_refresh_success_clears_stale(self): client = _FakeClient() mark_stale('fake') async def _cheap(): return True client._try_cheap_refresh = _cheap await client._auto_refresh() assert not is_stale('fake') async def test_no_refresh_handler_marks_stale_and_raises(self): client = _FakeClient() with pytest.raises(FakeAuthError, match='no automatic refresh'): await client._auto_refresh() assert is_stale('fake') async def test_full_refresh_reloads_env_and_clears_stale(self): class _Refreshable(_FakeClient): @staticmethod def refresh_credentials(): _FakeClient._test_env = {'FAKE_TOKEN': 'refreshed'} client = _Refreshable() mark_stale('fake') await client._auto_refresh() assert client.token == 'refreshed' assert client.cleared_cache == 1 assert not is_stale('fake') async def test_failed_refresh_marks_stale(self): class _Failing(_FakeClient): @staticmethod def refresh_credentials(): raise ServiceAuthError('login failed') client = _Failing() with pytest.raises(ServiceAuthError, match='login failed'): await client._auto_refresh() assert is_stale('fake') async def test_ensure_refreshed_single_flight(self): client = _FakeClient() calls = [] async def _refresh(): calls.append(True) await asyncio.sleep(0.01) client._auto_refresh = _refresh await asyncio.gather(client._ensure_refreshed(), client._ensure_refreshed(), client._ensure_refreshed()) assert calls == [True] async def test_full_skips_cheap_refresh(self): class _Refreshable(_FakeClient): @staticmethod def refresh_credentials(): _FakeClient._test_env = {'FAKE_TOKEN': 'refreshed'} client = _Refreshable() cheap_calls = [] async def _cheap(): cheap_calls.append(True) return True client._try_cheap_refresh = _cheap await client._ensure_refreshed(full=True) assert cheap_calls == [] assert client.token == 'refreshed' async def test_full_marks_stale_when_no_refresh_handler(self): client = _FakeClient() with pytest.raises(FakeAuthError, match='no automatic refresh'): await client._ensure_refreshed(full=True) assert is_stale('fake') async def test_full_runs_after_inflight_cheap_refresh(self): class _Refreshable(_FakeClient): @staticmethod def refresh_credentials(): pass client = _Refreshable() order = [] started = asyncio.Event() async def _cheap(): order.append('cheap') started.set() await asyncio.sleep(0.01) return True client._try_cheap_refresh = _cheap async def _full(): order.append('full') client._full_refresh = _full async def _late_full(): await started.wait() await client._ensure_refreshed(full=True) await asyncio.gather(client._ensure_refreshed(), _late_full()) assert order == ['cheap', 'full'] async def test_full_callers_share_one_attempt(self): class _Refreshable(_FakeClient): @staticmethod def refresh_credentials(): pass client = _Refreshable() calls = [] async def _full(): calls.append(True) await asyncio.sleep(0.01) client._full_refresh = _full await asyncio.gather(client._ensure_refreshed(full=True), client._ensure_refreshed(full=True)) assert calls == [True] class TestDefaults: async def test_probe_fetches_today(self): client = _FakeClient() fetched = [] async def _get_calendar(a, b): fetched.append((a, b)) return {} client.get_calendar = _get_calendar await client.probe() assert len(fetched) == 1 assert fetched[0][0] == fetched[0][1] def test_reload_from_env_applies_and_clears_cache(self): client = _FakeClient() _FakeClient._test_env = {'FAKE_TOKEN': 'new'} client._reload_from_env() assert client.token == 'new' assert client.cleared_cache == 1 def test_require_env_raises_listing_missing_keys(self): with pytest.raises(ValueError, match='FAKE_MISSING'): _FakeClient._require_env({'FAKE_TOKEN': 'x'}, 'FAKE_TOKEN', 'FAKE_MISSING')