test_api_client.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  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, 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_post_failure_logs_connection_lost_once(self, api):
  44. api._connected = True
  45. api._client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
  46. await api._post("/a", {})
  47. assert api._connected is False
  48. # Second failure should not log "connection lost" again
  49. await api._post("/b", {})
  50. assert len(api._buffer) == 2
  51. @pytest.mark.asyncio
  52. async def test_post_success_resets_backoff(self, api):
  53. api._backoff = 16.0
  54. mock_resp = MagicMock()
  55. mock_resp.json.return_value = {}
  56. mock_resp.raise_for_status = MagicMock()
  57. api._client.post = AsyncMock(return_value=mock_resp)
  58. await api._post("/test", {})
  59. assert api._backoff == 1.0
  60. @pytest.mark.asyncio
  61. async def test_buffer_max_size(self, api):
  62. api._client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
  63. for i in range(MAX_BUFFER_SIZE + 20):
  64. await api._post("/test", {"i": i})
  65. assert len(api._buffer) == MAX_BUFFER_SIZE
  66. # Oldest entries should have been dropped (deque maxlen behavior)
  67. assert api._buffer[0]["data"]["i"] == 20
  68. class TestHeartbeat:
  69. @pytest.mark.asyncio
  70. async def test_heartbeat_posts_to_correct_path(self, api):
  71. mock_resp = MagicMock()
  72. mock_resp.json.return_value = {"pending_command": None}
  73. mock_resp.raise_for_status = MagicMock()
  74. api._client.post = AsyncMock(return_value=mock_resp)
  75. result = await api.heartbeat(
  76. device_id="dev-1",
  77. nfc_ok=True,
  78. scale_ok=False,
  79. uptime_s=120,
  80. ip_address="192.168.1.50",
  81. firmware_version="0.2.2b1",
  82. )
  83. assert result == {"pending_command": None}
  84. call_args = api._client.post.call_args
  85. assert "/devices/dev-1/heartbeat" in call_args[0][0]
  86. @pytest.mark.asyncio
  87. async def test_heartbeat_flushes_buffer_on_success(self, api):
  88. # Pre-populate buffer
  89. api._buffer.append({"path": "/old", "data": {"x": 1}})
  90. mock_resp = MagicMock()
  91. mock_resp.json.return_value = {"ok": True}
  92. mock_resp.raise_for_status = MagicMock()
  93. api._client.post = AsyncMock(return_value=mock_resp)
  94. await api.heartbeat(device_id="d", nfc_ok=True, scale_ok=True, uptime_s=0)
  95. # Buffer should be flushed (post called for heartbeat + 1 buffered item)
  96. assert len(api._buffer) == 0
  97. @pytest.mark.asyncio
  98. async def test_heartbeat_returns_none_on_failure(self, api):
  99. api._client.post = AsyncMock(side_effect=httpx.ConnectError("fail"))
  100. result = await api.heartbeat(device_id="d", nfc_ok=True, scale_ok=True, uptime_s=0)
  101. assert result is None
  102. class TestRegisterDevice:
  103. @pytest.mark.asyncio
  104. async def test_register_retries_until_success(self, api):
  105. mock_resp = MagicMock()
  106. mock_resp.json.return_value = {"device_id": "dev-1"}
  107. mock_resp.raise_for_status = MagicMock()
  108. # Fail twice, then succeed
  109. call_count = 0
  110. async def mock_post(*args, **kwargs):
  111. nonlocal call_count
  112. call_count += 1
  113. if call_count <= 2:
  114. raise httpx.ConnectError("refused")
  115. return mock_resp
  116. api._client.post = mock_post
  117. # Speed up retries
  118. api._backoff = 0.01
  119. api._max_backoff = 0.02
  120. result = await api.register_device(
  121. device_id="dev-1",
  122. hostname="test",
  123. ip_address="1.2.3.4",
  124. )
  125. assert result == {"device_id": "dev-1"}
  126. assert call_count == 3
  127. @pytest.mark.asyncio
  128. async def test_register_sends_all_fields(self, api):
  129. mock_resp = MagicMock()
  130. mock_resp.json.return_value = {"ok": True}
  131. mock_resp.raise_for_status = MagicMock()
  132. api._client.post = AsyncMock(return_value=mock_resp)
  133. await api.register_device(
  134. device_id="dev-1",
  135. hostname="myhost",
  136. ip_address="10.0.0.1",
  137. firmware_version="0.2.2b1",
  138. has_nfc=True,
  139. has_scale=False,
  140. tare_offset=100,
  141. calibration_factor=1.05,
  142. nfc_reader_type="PN532",
  143. nfc_connection="SPI",
  144. has_backlight=True,
  145. )
  146. call_args = api._client.post.call_args
  147. payload = call_args[1]["json"]
  148. assert payload["device_id"] == "dev-1"
  149. assert payload["has_backlight"] is True
  150. assert payload["calibration_factor"] == 1.05
  151. class TestReportUpdateStatus:
  152. @pytest.mark.asyncio
  153. async def test_report_update_status(self, api):
  154. mock_resp = MagicMock()
  155. mock_resp.json.return_value = {"ok": True}
  156. mock_resp.raise_for_status = MagicMock()
  157. api._client.post = AsyncMock(return_value=mock_resp)
  158. result = await api.report_update_status("dev-1", "updating", "Fetching...")
  159. assert result == {"ok": True}
  160. call_args = api._client.post.call_args
  161. assert "/devices/dev-1/update-status" in call_args[0][0]
  162. payload = call_args[1]["json"]
  163. assert payload["status"] == "updating"
  164. assert payload["message"] == "Fetching..."
  165. @pytest.mark.asyncio
  166. async def test_report_update_status_failure_returns_none(self, api):
  167. api._client.post = AsyncMock(side_effect=httpx.ConnectError("fail"))
  168. result = await api.report_update_status("dev-1", "error", "oops")
  169. assert result is None