test_main.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. """Tests for daemon.main — _perform_update() and heartbeat_loop command dispatch."""
  2. import asyncio
  3. import sys
  4. import time
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from daemon.config import Config
  8. from daemon.main import _perform_update, heartbeat_loop
  9. def _make_config(**overrides):
  10. cfg = Config(
  11. backend_url="http://localhost:5000",
  12. api_key="test-key",
  13. device_id="dev-1",
  14. hostname="test-host",
  15. heartbeat_interval=0.01, # fast for tests
  16. )
  17. for k, v in overrides.items():
  18. setattr(cfg, k, v)
  19. return cfg
  20. def _make_api():
  21. api = AsyncMock()
  22. api.report_update_status = AsyncMock(return_value={"ok": True})
  23. api.heartbeat = AsyncMock(return_value=None)
  24. api.update_tare = AsyncMock(return_value={"ok": True})
  25. return api
  26. def _mock_process(returncode=0, stdout=b"", stderr=b""):
  27. proc = AsyncMock()
  28. proc.communicate = AsyncMock(return_value=(stdout, stderr))
  29. proc.returncode = returncode
  30. return proc
  31. class TestPerformUpdate:
  32. @pytest.mark.asyncio
  33. async def test_successful_update(self):
  34. config = _make_config()
  35. api = _make_api()
  36. proc_ok = _mock_process(returncode=0)
  37. with (
  38. patch("daemon.main.asyncio.create_subprocess_exec", return_value=proc_ok),
  39. patch("daemon.main.shutil.which", return_value="/usr/bin/git"),
  40. patch("daemon.main.Path") as mock_path_cls,
  41. pytest.raises(SystemExit) as exc_info,
  42. ):
  43. # Make venv pip not exist so it uses sys.executable path
  44. mock_path_inst = MagicMock()
  45. mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst
  46. mock_path_inst.__truediv__ = MagicMock(
  47. side_effect=lambda x: MagicMock(
  48. exists=MagicMock(return_value=False),
  49. __truediv__=MagicMock(return_value=MagicMock(exists=MagicMock(return_value=False))),
  50. __str__=MagicMock(return_value="/fake/repo"),
  51. )
  52. )
  53. mock_path_inst.__str__ = MagicMock(return_value="/fake/repo")
  54. await _perform_update(config, api)
  55. assert exc_info.value.code == 0
  56. # Should have reported status multiple times
  57. assert api.report_update_status.await_count >= 3
  58. # Last call should be "complete"
  59. last_call = api.report_update_status.call_args_list[-1]
  60. assert last_call[0][1] == "complete"
  61. @pytest.mark.asyncio
  62. async def test_git_fetch_failure(self):
  63. config = _make_config()
  64. api = _make_api()
  65. proc_fail = _mock_process(returncode=1, stderr=b"fatal: cannot fetch")
  66. with (
  67. patch("daemon.main.asyncio.create_subprocess_exec", return_value=proc_fail),
  68. patch("daemon.main.shutil.which", return_value="/usr/bin/git"),
  69. patch("daemon.main.Path") as mock_path_cls,
  70. ):
  71. mock_path_inst = MagicMock()
  72. mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst
  73. mock_path_inst.__str__ = MagicMock(return_value="/fake/repo")
  74. await _perform_update(config, api)
  75. # Should report error status
  76. error_calls = [c for c in api.report_update_status.call_args_list if c[0][1] == "error"]
  77. assert len(error_calls) == 1
  78. assert "git fetch failed" in error_calls[0][0][2]
  79. @pytest.mark.asyncio
  80. async def test_git_reset_failure(self):
  81. config = _make_config()
  82. api = _make_api()
  83. call_count = 0
  84. async def mock_exec(*args, **kwargs):
  85. nonlocal call_count
  86. call_count += 1
  87. if call_count == 1:
  88. # git fetch succeeds
  89. return _mock_process(returncode=0)
  90. else:
  91. # git reset fails
  92. return _mock_process(returncode=1, stderr=b"reset error")
  93. with (
  94. patch("daemon.main.asyncio.create_subprocess_exec", side_effect=mock_exec),
  95. patch("daemon.main.shutil.which", return_value="/usr/bin/git"),
  96. patch("daemon.main.Path") as mock_path_cls,
  97. ):
  98. mock_path_inst = MagicMock()
  99. mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst
  100. mock_path_inst.__str__ = MagicMock(return_value="/fake/repo")
  101. await _perform_update(config, api)
  102. error_calls = [c for c in api.report_update_status.call_args_list if c[0][1] == "error"]
  103. assert len(error_calls) == 1
  104. assert "git reset failed" in error_calls[0][0][2]
  105. class TestHeartbeatLoopCommands:
  106. """Test command dispatch in heartbeat_loop."""
  107. @pytest.mark.asyncio
  108. async def test_update_command_triggers_perform_update(self):
  109. config = _make_config()
  110. api = _make_api()
  111. # First heartbeat returns update command, second returns None to break
  112. call_count = 0
  113. async def mock_heartbeat(*args, **kwargs):
  114. nonlocal call_count
  115. call_count += 1
  116. if call_count == 1:
  117. return {"pending_command": "update"}
  118. return None
  119. api.heartbeat = mock_heartbeat
  120. display = MagicMock()
  121. display.set_brightness = MagicMock()
  122. display.set_blank_timeout = MagicMock()
  123. display.tick = MagicMock()
  124. shared = {"nfc": None, "scale": None, "display": display}
  125. with patch("daemon.main._perform_update", new_callable=AsyncMock) as mock_update:
  126. # Run for 2 iterations then cancel
  127. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  128. await asyncio.sleep(0.1)
  129. task.cancel()
  130. try:
  131. await task
  132. except asyncio.CancelledError:
  133. pass
  134. mock_update.assert_awaited_once_with(config, api)
  135. @pytest.mark.asyncio
  136. async def test_update_command_reports_error_on_exception(self):
  137. config = _make_config()
  138. api = _make_api()
  139. call_count = 0
  140. async def mock_heartbeat(*args, **kwargs):
  141. nonlocal call_count
  142. call_count += 1
  143. if call_count == 1:
  144. return {"pending_command": "update"}
  145. return None
  146. api.heartbeat = mock_heartbeat
  147. display = MagicMock()
  148. display.tick = MagicMock()
  149. shared = {"nfc": None, "scale": None, "display": display}
  150. with patch("daemon.main._perform_update", new_callable=AsyncMock, side_effect=RuntimeError("boom")):
  151. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  152. await asyncio.sleep(0.1)
  153. task.cancel()
  154. try:
  155. await task
  156. except asyncio.CancelledError:
  157. pass
  158. api.report_update_status.assert_awaited()
  159. error_call = api.report_update_status.call_args
  160. assert error_call[0][1] == "error"
  161. @pytest.mark.asyncio
  162. async def test_tare_command_executes_scale_tare(self):
  163. config = _make_config()
  164. api = _make_api()
  165. call_count = 0
  166. async def mock_heartbeat(*args, **kwargs):
  167. nonlocal call_count
  168. call_count += 1
  169. if call_count == 1:
  170. return {"pending_command": "tare"}
  171. return None
  172. api.heartbeat = mock_heartbeat
  173. scale = MagicMock()
  174. scale.ok = True
  175. scale.tare = MagicMock(return_value=512)
  176. display = MagicMock()
  177. display.tick = MagicMock()
  178. shared = {"nfc": None, "scale": scale, "display": display}
  179. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  180. await asyncio.sleep(0.1)
  181. task.cancel()
  182. try:
  183. await task
  184. except asyncio.CancelledError:
  185. pass
  186. scale.tare.assert_called_once()
  187. api.update_tare.assert_awaited_once_with("dev-1", 512)
  188. assert config.tare_offset == 512
  189. @pytest.mark.asyncio
  190. async def test_tare_command_no_scale_logs_warning(self):
  191. config = _make_config()
  192. api = _make_api()
  193. call_count = 0
  194. async def mock_heartbeat(*args, **kwargs):
  195. nonlocal call_count
  196. call_count += 1
  197. if call_count == 1:
  198. return {"pending_command": "tare"}
  199. return None
  200. api.heartbeat = mock_heartbeat
  201. display = MagicMock()
  202. display.tick = MagicMock()
  203. shared = {"nfc": None, "scale": None, "display": display}
  204. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  205. await asyncio.sleep(0.1)
  206. task.cancel()
  207. try:
  208. await task
  209. except asyncio.CancelledError:
  210. pass
  211. # Should not crash; update_tare should NOT be called
  212. api.update_tare.assert_not_awaited()
  213. @pytest.mark.asyncio
  214. async def test_write_tag_command_sets_pending_write(self):
  215. config = _make_config()
  216. api = _make_api()
  217. call_count = 0
  218. async def mock_heartbeat(*args, **kwargs):
  219. nonlocal call_count
  220. call_count += 1
  221. if call_count == 1:
  222. return {
  223. "pending_command": "write_tag",
  224. "pending_write_payload": {
  225. "spool_id": 42,
  226. "ndef_data_hex": "DEADBEEF",
  227. },
  228. }
  229. return None
  230. api.heartbeat = mock_heartbeat
  231. display = MagicMock()
  232. display.tick = MagicMock()
  233. display.set_brightness = MagicMock()
  234. display.set_blank_timeout = MagicMock()
  235. shared = {"nfc": None, "scale": None, "display": display}
  236. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  237. await asyncio.sleep(0.1)
  238. task.cancel()
  239. try:
  240. await task
  241. except asyncio.CancelledError:
  242. pass
  243. assert "pending_write" in shared
  244. assert shared["pending_write"]["spool_id"] == 42
  245. assert shared["pending_write"]["ndef_data"] == bytes.fromhex("DEADBEEF")
  246. @pytest.mark.asyncio
  247. async def test_display_settings_applied_from_heartbeat(self):
  248. config = _make_config()
  249. api = _make_api()
  250. call_count = 0
  251. async def mock_heartbeat(*args, **kwargs):
  252. nonlocal call_count
  253. call_count += 1
  254. if call_count == 1:
  255. return {
  256. "display_brightness": 75,
  257. "display_blank_timeout": 300,
  258. }
  259. return None
  260. api.heartbeat = mock_heartbeat
  261. display = MagicMock()
  262. display.tick = MagicMock()
  263. shared = {"nfc": None, "scale": None, "display": display}
  264. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  265. await asyncio.sleep(0.1)
  266. task.cancel()
  267. try:
  268. await task
  269. except asyncio.CancelledError:
  270. pass
  271. display.set_brightness.assert_called_with(75)
  272. display.set_blank_timeout.assert_called_with(300)
  273. @pytest.mark.asyncio
  274. async def test_calibration_sync_from_heartbeat(self):
  275. config = _make_config(tare_offset=0, calibration_factor=1.0)
  276. api = _make_api()
  277. call_count = 0
  278. async def mock_heartbeat(*args, **kwargs):
  279. nonlocal call_count
  280. call_count += 1
  281. if call_count == 1:
  282. return {
  283. "tare_offset": 200,
  284. "calibration_factor": 1.05,
  285. }
  286. return None
  287. api.heartbeat = mock_heartbeat
  288. scale = MagicMock()
  289. scale.ok = True
  290. display = MagicMock()
  291. display.tick = MagicMock()
  292. shared = {"nfc": None, "scale": scale, "display": display}
  293. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  294. await asyncio.sleep(0.1)
  295. task.cancel()
  296. try:
  297. await task
  298. except asyncio.CancelledError:
  299. pass
  300. assert config.tare_offset == 200
  301. assert config.calibration_factor == 1.05
  302. scale.update_calibration.assert_called_with(200, 1.05)