test_service_base.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  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_http_error_status_raises_plain(self):
  96. client = _FakeClient()
  97. with pytest.raises(httpx.HTTPStatusError):
  98. await client._request(lambda: asyncio.sleep(0, _resp(500)), op='x')
  99. assert not is_stale('fake')
  100. class TestAutoRefresh:
  101. async def test_cheap_refresh_success_clears_stale(self):
  102. client = _FakeClient()
  103. mark_stale('fake')
  104. async def _cheap():
  105. return True
  106. client._try_cheap_refresh = _cheap
  107. await client._auto_refresh()
  108. assert not is_stale('fake')
  109. async def test_no_refresh_handler_marks_stale_and_raises(self):
  110. client = _FakeClient()
  111. with pytest.raises(FakeAuthError, match='no automatic refresh'):
  112. await client._auto_refresh()
  113. assert is_stale('fake')
  114. async def test_full_refresh_reloads_env_and_clears_stale(self):
  115. class _Refreshable(_FakeClient):
  116. @staticmethod
  117. def refresh_credentials():
  118. _FakeClient._test_env = {'FAKE_TOKEN': 'refreshed'}
  119. client = _Refreshable()
  120. mark_stale('fake')
  121. await client._auto_refresh()
  122. assert client.token == 'refreshed'
  123. assert client.cleared_cache == 1
  124. assert not is_stale('fake')
  125. async def test_failed_refresh_marks_stale(self):
  126. class _Failing(_FakeClient):
  127. @staticmethod
  128. def refresh_credentials():
  129. raise ServiceAuthError('login failed')
  130. client = _Failing()
  131. with pytest.raises(ServiceAuthError, match='login failed'):
  132. await client._auto_refresh()
  133. assert is_stale('fake')
  134. async def test_ensure_refreshed_single_flight(self):
  135. client = _FakeClient()
  136. calls = []
  137. async def _refresh():
  138. calls.append(True)
  139. await asyncio.sleep(0.01)
  140. client._auto_refresh = _refresh
  141. await asyncio.gather(client._ensure_refreshed(), client._ensure_refreshed(), client._ensure_refreshed())
  142. assert calls == [True]
  143. class TestDefaults:
  144. async def test_probe_fetches_today(self):
  145. client = _FakeClient()
  146. fetched = []
  147. async def _get_calendar(a, b):
  148. fetched.append((a, b))
  149. return {}
  150. client.get_calendar = _get_calendar
  151. await client.probe()
  152. assert len(fetched) == 1
  153. assert fetched[0][0] == fetched[0][1]
  154. def test_reload_from_env_applies_and_clears_cache(self):
  155. client = _FakeClient()
  156. _FakeClient._test_env = {'FAKE_TOKEN': 'new'}
  157. client._reload_from_env()
  158. assert client.token == 'new'
  159. assert client.cleared_cache == 1
  160. def test_require_env_raises_listing_missing_keys(self):
  161. with pytest.raises(ValueError, match='FAKE_MISSING'):
  162. _FakeClient._require_env({'FAKE_TOKEN': 'x'}, 'FAKE_TOKEN', 'FAKE_MISSING')