test_api_client.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. """Tests for daemon.api_client — APIClient HTTP communication."""
  2. import asyncio
  3. from unittest.mock import AsyncMock, MagicMock, patch
  4. import httpx
  5. import pytest
  6. from daemon.api_client import MAX_BUFFER_SIZE, WRITE_RESULT_PATH, APIClient
  7. @pytest.fixture
  8. def api():
  9. return APIClient("http://localhost:5000", "test-key")
  10. class TestAPIClientInit:
  11. def test_base_url_construction(self, api):
  12. assert api._base == "http://localhost:5000/api/v1/spoolbuddy"
  13. def test_base_url_strips_trailing_slash(self):
  14. client = APIClient("http://localhost:5000/", "key")
  15. assert client._base == "http://localhost:5000/api/v1/spoolbuddy"
  16. def test_api_key_in_headers(self):
  17. client = APIClient("http://localhost:5000", "my-key")
  18. assert client._headers == {"X-API-Key": "my-key"}
  19. def test_no_api_key_empty_headers(self):
  20. client = APIClient("http://localhost:5000", "")
  21. assert client._headers == {}
  22. class TestPost:
  23. @pytest.mark.asyncio
  24. async def test_post_success(self, api):
  25. mock_resp = MagicMock()
  26. mock_resp.status_code = 200
  27. mock_resp.json.return_value = {"ok": True}
  28. mock_resp.raise_for_status = MagicMock()
  29. api._client.post = AsyncMock(return_value=mock_resp)
  30. result = await api._post("/test", {"key": "value"})
  31. assert result == {"ok": True}
  32. assert api._connected is True
  33. assert api._backoff == 1.0
  34. api._client.post.assert_awaited_once()
  35. @pytest.mark.asyncio
  36. async def test_post_failure_buffers_request(self, api):
  37. api._client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
  38. result = await api._post("/test", {"data": 1})
  39. assert result is None
  40. assert len(api._buffer) == 1
  41. assert api._buffer[0] == {"path": "/test", "data": {"data": 1}}
  42. @pytest.mark.asyncio
  43. async def test_a_buffered_write_result_is_reported(self, api):
  44. # #3076: main must know a tag write's result is still unsent.
  45. api._client.post = AsyncMock(side_effect=httpx.ConnectError("down"))
  46. assert not api.has_buffered(WRITE_RESULT_PATH)
  47. await api.write_tag_result("dev-1", 7, "04A1", True, "ok")
  48. assert api.has_buffered(WRITE_RESULT_PATH)
  49. assert not api.has_buffered("/heartbeat")
  50. @pytest.mark.asyncio
  51. async def test_post_failure_logs_connection_lost_once(self, api):
  52. api._connected = True
  53. api._client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
  54. await api._post("/a", {})
  55. assert api._connected is False
  56. # Second failure should not log "connection lost" again
  57. await api._post("/b", {})
  58. assert len(api._buffer) == 2
  59. @pytest.mark.asyncio
  60. async def test_post_success_resets_backoff(self, api):
  61. api._backoff = 16.0
  62. mock_resp = MagicMock()
  63. mock_resp.json.return_value = {}
  64. mock_resp.raise_for_status = MagicMock()
  65. api._client.post = AsyncMock(return_value=mock_resp)
  66. await api._post("/test", {})
  67. assert api._backoff == 1.0
  68. @pytest.mark.asyncio
  69. async def test_buffer_max_size(self, api):
  70. api._client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
  71. for i in range(MAX_BUFFER_SIZE + 20):
  72. await api._post("/test", {"i": i})
  73. assert len(api._buffer) == MAX_BUFFER_SIZE
  74. # Oldest entries should have been dropped (deque maxlen behavior)
  75. assert api._buffer[0]["data"]["i"] == 20
  76. class TestHeartbeat:
  77. @pytest.mark.asyncio
  78. async def test_heartbeat_posts_to_correct_path(self, api):
  79. mock_resp = MagicMock()
  80. mock_resp.json.return_value = {"pending_command": None}
  81. mock_resp.raise_for_status = MagicMock()
  82. api._client.post = AsyncMock(return_value=mock_resp)
  83. result = await api.heartbeat(
  84. device_id="dev-1",
  85. nfc_ok=True,
  86. scale_ok=False,
  87. uptime_s=120,
  88. ip_address="192.168.1.50",
  89. firmware_version="0.2.2b1",
  90. )
  91. assert result == {"pending_command": None}
  92. call_args = api._client.post.call_args
  93. assert "/devices/dev-1/heartbeat" in call_args[0][0]
  94. @pytest.mark.asyncio
  95. async def test_heartbeat_flushes_buffer_on_success(self, api):
  96. # Pre-populate buffer
  97. api._buffer.append({"path": "/old", "data": {"x": 1}})
  98. mock_resp = MagicMock()
  99. mock_resp.json.return_value = {"ok": True}
  100. mock_resp.raise_for_status = MagicMock()
  101. api._client.post = AsyncMock(return_value=mock_resp)
  102. await api.heartbeat(device_id="d", nfc_ok=True, scale_ok=True, uptime_s=0)
  103. # Buffer should be flushed (post called for heartbeat + 1 buffered item)
  104. assert len(api._buffer) == 0
  105. @pytest.mark.asyncio
  106. async def test_heartbeat_returns_none_on_failure(self, api):
  107. api._client.post = AsyncMock(side_effect=httpx.ConnectError("fail"))
  108. result = await api.heartbeat(device_id="d", nfc_ok=True, scale_ok=True, uptime_s=0)
  109. assert result is None
  110. class TestRegisterDevice:
  111. @pytest.mark.asyncio
  112. async def test_register_retries_until_success(self, api):
  113. mock_resp = MagicMock()
  114. mock_resp.json.return_value = {"device_id": "dev-1"}
  115. mock_resp.raise_for_status = MagicMock()
  116. # Fail twice, then succeed
  117. call_count = 0
  118. async def mock_post(*args, **kwargs):
  119. nonlocal call_count
  120. call_count += 1
  121. if call_count <= 2:
  122. raise httpx.ConnectError("refused")
  123. return mock_resp
  124. api._client.post = mock_post
  125. # Speed up retries
  126. api._backoff = 0.01
  127. api._max_backoff = 0.02
  128. result = await api.register_device(
  129. device_id="dev-1",
  130. hostname="test",
  131. ip_address="1.2.3.4",
  132. )
  133. assert result == {"device_id": "dev-1"}
  134. assert call_count == 3
  135. @pytest.mark.asyncio
  136. async def test_register_sends_all_fields(self, api):
  137. mock_resp = MagicMock()
  138. mock_resp.json.return_value = {"ok": True}
  139. mock_resp.raise_for_status = MagicMock()
  140. api._client.post = AsyncMock(return_value=mock_resp)
  141. await api.register_device(
  142. device_id="dev-1",
  143. hostname="myhost",
  144. ip_address="10.0.0.1",
  145. firmware_version="0.2.2b1",
  146. has_nfc=True,
  147. has_scale=False,
  148. tare_offset=100,
  149. calibration_factor=1.05,
  150. nfc_reader_type="PN532",
  151. nfc_connection="SPI",
  152. has_backlight=True,
  153. )
  154. call_args = api._client.post.call_args
  155. payload = call_args[1]["json"]
  156. assert payload["device_id"] == "dev-1"
  157. assert payload["has_backlight"] is True
  158. assert payload["calibration_factor"] == 1.05
  159. def _status_error(code: int, detail: str | None = None) -> httpx.HTTPStatusError:
  160. request = httpx.Request("POST", "http://localhost:5000/x")
  161. body = {"json": {"detail": detail}} if detail is not None else {}
  162. return httpx.HTTPStatusError("rejected", request=request, response=httpx.Response(code, request=request, **body))
  163. class TestTagScanned:
  164. @pytest.mark.asyncio
  165. async def test_sends_raw_blocks(self, api):
  166. api._post = AsyncMock(return_value={"status": "ok"})
  167. raw = {"1": "4130302D423038004746413030000000", "9": "36B6C12DB3DA4A39A50C275F510BD399"}
  168. await api.tag_scanned("sb-1", "865947E2", tray_uuid="36B6C12DB3DA4A39A50C275F510BD399", raw_blocks=raw)
  169. path, payload = api._post.await_args.args
  170. assert path == "/nfc/tag-scanned"
  171. assert payload["raw_blocks"] == raw
  172. assert payload["tray_uuid"] == "36B6C12DB3DA4A39A50C275F510BD399"
  173. @pytest.mark.asyncio
  174. async def test_raw_blocks_default_to_none(self, api):
  175. api._post = AsyncMock(return_value={"status": "ok"})
  176. await api.tag_scanned("sb-1", "04A1B2C3D4E5F6")
  177. _, payload = api._post.await_args.args
  178. assert payload["raw_blocks"] is None
  179. class TestRejectedRequests:
  180. """A request the backend refuses is not a connection problem: buffering it
  181. replays it forever, and the flush stops at the first item that fails."""
  182. @pytest.mark.asyncio
  183. async def test_a_heartbeat_for_an_unregistered_device_is_not_buffered(self, api):
  184. resp = MagicMock()
  185. resp.raise_for_status = MagicMock(side_effect=_status_error(404, "Device not registered"))
  186. api._client.post = AsyncMock(return_value=resp)
  187. result = await api.heartbeat(device_id="dev-1", nfc_ok=True, scale_ok=True, uptime_s=1)
  188. assert result is None
  189. assert len(api._buffer) == 0
  190. assert api.unregistered is True
  191. @pytest.mark.asyncio
  192. async def test_a_proxys_bare_404_is_not_taken_for_an_unregistered_device(self, api):
  193. """A reverse proxy answers 404 while Bambuddy restarts."""
  194. resp = MagicMock()
  195. resp.raise_for_status = MagicMock(side_effect=_status_error(404))
  196. api._client.post = AsyncMock(return_value=resp)
  197. await api.heartbeat(device_id="dev-1", nfc_ok=True, scale_ok=True, uptime_s=1)
  198. assert api.unregistered is False
  199. @pytest.mark.asyncio
  200. async def test_a_connection_error_is_still_buffered(self, api):
  201. api._client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
  202. await api.heartbeat(device_id="dev-1", nfc_ok=True, scale_ok=True, uptime_s=1)
  203. assert len(api._buffer) == 1
  204. assert api.unregistered is False
  205. @pytest.mark.asyncio
  206. async def test_a_rejected_buffered_item_does_not_block_the_rest(self, api):
  207. api._buffer.append({"path": "/devices/dev-1/heartbeat", "data": {}})
  208. api._buffer.append({"path": "/scale/reading", "data": {"w": 1}})
  209. rejected = MagicMock(raise_for_status=MagicMock(side_effect=_status_error(404)))
  210. accepted = MagicMock(raise_for_status=MagicMock())
  211. api._client.post = AsyncMock(side_effect=[rejected, accepted])
  212. await api._flush_buffer()
  213. assert len(api._buffer) == 0
  214. assert api._client.post.await_count == 2