| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- """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')
|