test_service_base.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. """Unit tests for service_api._base (ServiceClientBase).
  2. Covers the cross-client invariants: stale fast-fail, error classification
  3. (timeouts are never auth errors), the attempt → refresh → retry-once request
  4. skeleton, and refresh orchestration bookkeeping.
  5. """
  6. import asyncio
  7. import httpx
  8. import pytest
  9. from room_cal.service_api._base import ServiceClientBase
  10. from room_cal.service_api.credentials import clear_stale, is_stale, mark_stale
  11. from room_cal.service_api.types import ServiceAuthError
  12. class FakeAuthError(ServiceAuthError):
  13. pass
  14. class _FakeClient(ServiceClientBase):
  15. service_key = 'fake'
  16. service_name = 'Fake'
  17. auth_error_cls = FakeAuthError
  18. def __init__(self):
  19. super().__init__()
  20. self.env = {'FAKE_TOKEN': 'initial'}
  21. self.token = 'initial'
  22. self.cleared_cache = 0
  23. @classmethod
  24. def _read_env(cls): # avoid the filesystem
  25. return _FakeClient._test_env
  26. def _apply_env(self, env):
  27. self.token = env.get('FAKE_TOKEN', self.token)
  28. def clear_cache(self):
  29. self.cleared_cache += 1
  30. _FakeClient._test_env = {'FAKE_TOKEN': 'initial'}
  31. def _resp(status, body=''):
  32. return httpx.Response(status, text=body, request=httpx.Request('GET', 'https://fake.test/x'))
  33. @pytest.fixture(autouse=True)
  34. def _fresh():
  35. clear_stale('fake')
  36. _FakeClient._test_env = {'FAKE_TOKEN': 'initial'}
  37. yield
  38. clear_stale('fake')
  39. class TestCheckFresh:
  40. def test_passes_when_fresh(self):
  41. _FakeClient()._check_fresh()
  42. def test_raises_when_stale(self):
  43. mark_stale('fake')
  44. with pytest.raises(FakeAuthError, match='credentials are stale'):
  45. _FakeClient()._check_fresh()
  46. class TestRequest:
  47. async def test_success_passthrough(self):
  48. client = _FakeClient()
  49. r = await client._request(lambda: asyncio.sleep(0, _resp(200, 'ok')), op='x')
  50. assert r.status_code == 200
  51. async def test_timeout_is_not_auth_error(self):
  52. client = _FakeClient()
  53. async def _attempt():
  54. raise httpx.ReadTimeout('slow')
  55. with pytest.raises(httpx.TimeoutException):
  56. await client._request(_attempt, op='x')
  57. assert not is_stale('fake')
  58. async def test_transport_error_becomes_auth_error(self):
  59. client = _FakeClient()
  60. async def _attempt():
  61. raise httpx.ConnectError('refused')
  62. with pytest.raises(FakeAuthError, match='connection failed'):
  63. await client._request(_attempt, op='x')
  64. async def test_auth_status_refreshes_and_retries_once(self):
  65. client = _FakeClient()
  66. refreshes = []
  67. async def _refresh():
  68. refreshes.append(True)
  69. client._auto_refresh = _refresh
  70. responses = [_resp(401), _resp(200)]
  71. r = await client._request(lambda: asyncio.sleep(0, responses.pop(0)), op='x')
  72. assert r.status_code == 200
  73. assert refreshes == [True]
  74. async def test_second_auth_status_marks_stale_and_raises(self):
  75. client = _FakeClient()
  76. async def _refresh():
  77. pass
  78. client._auto_refresh = _refresh
  79. responses = [_resp(401), _resp(403)]
  80. with pytest.raises(FakeAuthError, match='session expired \\(HTTP 403\\)'):
  81. await client._request(lambda: asyncio.sleep(0, responses.pop(0)), op='x')
  82. assert is_stale('fake')
  83. async def test_retry_sees_state_reloaded_by_refresh(self):
  84. """The ses lesson: attempt closures must read client state per call."""
  85. client = _FakeClient()
  86. seen = []
  87. async def _attempt():
  88. seen.append(client.token)
  89. return _resp(401 if len(seen) == 1 else 200)
  90. async def _refresh():
  91. client.token = 'refreshed'
  92. client._auto_refresh = _refresh
  93. await client._request(_attempt, op='x')
  94. assert seen == ['initial', 'refreshed']
  95. async def test_body_shaped_auth_refreshes_and_retries_once(self):
  96. """A client overriding _auth_shaped gets the same retry as status-shaped auth."""
  97. class _BodyAuthClient(_FakeClient):
  98. def _auth_shaped(self, r):
  99. return super()._auth_shaped(r) or 'expired' in r.text
  100. client = _BodyAuthClient()
  101. refreshes = []
  102. async def _refresh():
  103. refreshes.append(True)
  104. client._auto_refresh = _refresh
  105. responses = [_resp(200, 'expired'), _resp(200, 'ok')]
  106. r = await client._request(lambda: asyncio.sleep(0, responses.pop(0)), op='x')
  107. assert r.text == 'ok'
  108. assert refreshes == [True]
  109. async def test_body_shaped_auth_twice_marks_stale_and_raises(self):
  110. class _BodyAuthClient(_FakeClient):
  111. def _auth_shaped(self, r):
  112. return super()._auth_shaped(r) or 'expired' in r.text
  113. client = _BodyAuthClient()
  114. async def _refresh():
  115. pass
  116. client._auto_refresh = _refresh
  117. responses = [_resp(200, 'expired'), _resp(200, 'expired')]
  118. with pytest.raises(FakeAuthError, match='session expired'):
  119. await client._request(lambda: asyncio.sleep(0, responses.pop(0)), op='x')
  120. assert is_stale('fake')
  121. async def test_http_error_status_raises_plain(self):
  122. client = _FakeClient()
  123. with pytest.raises(httpx.HTTPStatusError):
  124. await client._request(lambda: asyncio.sleep(0, _resp(500)), op='x')
  125. assert not is_stale('fake')
  126. class TestAutoRefresh:
  127. async def test_cheap_refresh_success_clears_stale(self):
  128. client = _FakeClient()
  129. mark_stale('fake')
  130. async def _cheap():
  131. return True
  132. client._try_cheap_refresh = _cheap
  133. await client._auto_refresh()
  134. assert not is_stale('fake')
  135. async def test_no_refresh_handler_marks_stale_and_raises(self):
  136. client = _FakeClient()
  137. with pytest.raises(FakeAuthError, match='no automatic refresh'):
  138. await client._auto_refresh()
  139. assert is_stale('fake')
  140. async def test_full_refresh_reloads_env_and_clears_stale(self):
  141. class _Refreshable(_FakeClient):
  142. @staticmethod
  143. def refresh_credentials():
  144. _FakeClient._test_env = {'FAKE_TOKEN': 'refreshed'}
  145. client = _Refreshable()
  146. mark_stale('fake')
  147. await client._auto_refresh()
  148. assert client.token == 'refreshed'
  149. assert client.cleared_cache == 1
  150. assert not is_stale('fake')
  151. async def test_failed_refresh_marks_stale(self):
  152. class _Failing(_FakeClient):
  153. @staticmethod
  154. def refresh_credentials():
  155. raise ServiceAuthError('login failed')
  156. client = _Failing()
  157. with pytest.raises(ServiceAuthError, match='login failed'):
  158. await client._auto_refresh()
  159. assert is_stale('fake')
  160. async def test_ensure_refreshed_single_flight(self):
  161. client = _FakeClient()
  162. calls = []
  163. async def _refresh():
  164. calls.append(True)
  165. await asyncio.sleep(0.01)
  166. client._auto_refresh = _refresh
  167. await asyncio.gather(client._ensure_refreshed(), client._ensure_refreshed(), client._ensure_refreshed())
  168. assert calls == [True]
  169. async def test_full_skips_cheap_refresh(self):
  170. class _Refreshable(_FakeClient):
  171. @staticmethod
  172. def refresh_credentials():
  173. _FakeClient._test_env = {'FAKE_TOKEN': 'refreshed'}
  174. client = _Refreshable()
  175. cheap_calls = []
  176. async def _cheap():
  177. cheap_calls.append(True)
  178. return True
  179. client._try_cheap_refresh = _cheap
  180. await client._ensure_refreshed(full=True)
  181. assert cheap_calls == []
  182. assert client.token == 'refreshed'
  183. async def test_full_marks_stale_when_no_refresh_handler(self):
  184. client = _FakeClient()
  185. with pytest.raises(FakeAuthError, match='no automatic refresh'):
  186. await client._ensure_refreshed(full=True)
  187. assert is_stale('fake')
  188. async def test_full_runs_after_inflight_cheap_refresh(self):
  189. class _Refreshable(_FakeClient):
  190. @staticmethod
  191. def refresh_credentials():
  192. pass
  193. client = _Refreshable()
  194. order = []
  195. started = asyncio.Event()
  196. async def _cheap():
  197. order.append('cheap')
  198. started.set()
  199. await asyncio.sleep(0.01)
  200. return True
  201. client._try_cheap_refresh = _cheap
  202. async def _full():
  203. order.append('full')
  204. client._full_refresh = _full
  205. async def _late_full():
  206. await started.wait()
  207. await client._ensure_refreshed(full=True)
  208. await asyncio.gather(client._ensure_refreshed(), _late_full())
  209. assert order == ['cheap', 'full']
  210. async def test_full_callers_share_one_attempt(self):
  211. class _Refreshable(_FakeClient):
  212. @staticmethod
  213. def refresh_credentials():
  214. pass
  215. client = _Refreshable()
  216. calls = []
  217. async def _full():
  218. calls.append(True)
  219. await asyncio.sleep(0.01)
  220. client._full_refresh = _full
  221. await asyncio.gather(client._ensure_refreshed(full=True), client._ensure_refreshed(full=True))
  222. assert calls == [True]
  223. class TestDefaults:
  224. async def test_probe_fetches_today(self):
  225. client = _FakeClient()
  226. fetched = []
  227. async def _get_calendar(a, b):
  228. fetched.append((a, b))
  229. return {}
  230. client.get_calendar = _get_calendar
  231. await client.probe()
  232. assert len(fetched) == 1
  233. assert fetched[0][0] == fetched[0][1]
  234. def test_reload_from_env_applies_and_clears_cache(self):
  235. client = _FakeClient()
  236. _FakeClient._test_env = {'FAKE_TOKEN': 'new'}
  237. client._reload_from_env()
  238. assert client.token == 'new'
  239. assert client.cleared_cache == 1
  240. def test_require_env_raises_listing_missing_keys(self):
  241. with pytest.raises(ValueError, match='FAKE_MISSING'):
  242. _FakeClient._require_env({'FAKE_TOKEN': 'x'}, 'FAKE_TOKEN', 'FAKE_MISSING')