test_spoolbuddy.py 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196
  1. """Integration tests for SpoolBuddy API endpoints."""
  2. from datetime import datetime, timedelta, timezone
  3. from unittest.mock import AsyncMock, MagicMock, patch
  4. import pytest
  5. from httpx import AsyncClient
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.api.routes import spoolbuddy as spoolbuddy_routes
  8. from backend.app.models.spool import Spool
  9. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  10. API = "/api/v1/spoolbuddy"
  11. @pytest.fixture
  12. def device_factory(db_session: AsyncSession):
  13. """Factory to create SpoolBuddyDevice records."""
  14. _counter = [0]
  15. async def _create(**kwargs):
  16. _counter[0] += 1
  17. n = _counter[0]
  18. defaults = {
  19. "device_id": f"sb-{n:04d}",
  20. "hostname": f"spoolbuddy-{n}",
  21. "ip_address": f"10.0.0.{n}",
  22. "firmware_version": "1.0.0",
  23. "has_nfc": True,
  24. "has_scale": True,
  25. "tare_offset": 0,
  26. "calibration_factor": 1.0,
  27. "last_seen": datetime.now(timezone.utc),
  28. }
  29. defaults.update(kwargs)
  30. device = SpoolBuddyDevice(**defaults)
  31. db_session.add(device)
  32. await db_session.commit()
  33. await db_session.refresh(device)
  34. return device
  35. return _create
  36. @pytest.fixture
  37. def spool_factory(db_session: AsyncSession):
  38. """Factory to create Spool records."""
  39. _counter = [0]
  40. async def _create(**kwargs):
  41. _counter[0] += 1
  42. defaults = {
  43. "material": "PLA",
  44. "subtype": "Basic",
  45. "brand": "Polymaker",
  46. "color_name": "Red",
  47. "rgba": "FF0000FF",
  48. "label_weight": 1000,
  49. "core_weight": 250,
  50. "weight_used": 0,
  51. }
  52. defaults.update(kwargs)
  53. spool = Spool(**defaults)
  54. db_session.add(spool)
  55. await db_session.commit()
  56. await db_session.refresh(spool)
  57. return spool
  58. return _create
  59. # ============================================================================
  60. # Device endpoints
  61. # ============================================================================
  62. class TestDeviceEndpoints:
  63. @pytest.mark.asyncio
  64. @pytest.mark.integration
  65. async def test_register_new_device(self, async_client: AsyncClient):
  66. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  67. mock_ws.broadcast = AsyncMock()
  68. resp = await async_client.post(
  69. f"{API}/devices/register",
  70. json={
  71. "device_id": "sb-new",
  72. "hostname": "spoolbuddy-new",
  73. "ip_address": "10.0.0.99",
  74. "firmware_version": "1.2.0",
  75. },
  76. )
  77. assert resp.status_code == 200
  78. data = resp.json()
  79. assert data["device_id"] == "sb-new"
  80. assert data["hostname"] == "spoolbuddy-new"
  81. assert data["online"] is True
  82. mock_ws.broadcast.assert_called_once()
  83. msg = mock_ws.broadcast.call_args[0][0]
  84. assert msg["type"] == "spoolbuddy_online"
  85. @pytest.mark.asyncio
  86. @pytest.mark.integration
  87. async def test_re_register_existing_device(self, async_client: AsyncClient, device_factory):
  88. device = await device_factory(
  89. device_id="sb-exist",
  90. tare_offset=12345,
  91. calibration_factor=0.0042,
  92. )
  93. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  94. mock_ws.broadcast = AsyncMock()
  95. resp = await async_client.post(
  96. f"{API}/devices/register",
  97. json={
  98. "device_id": "sb-exist",
  99. "hostname": "updated-host",
  100. "ip_address": "10.0.0.200",
  101. "firmware_version": "2.0.0",
  102. },
  103. )
  104. assert resp.status_code == 200
  105. data = resp.json()
  106. assert data["id"] == device.id
  107. assert data["hostname"] == "updated-host"
  108. assert data["ip_address"] == "10.0.0.200"
  109. assert data["firmware_version"] == "2.0.0"
  110. # Calibration preserved on re-register
  111. assert data["tare_offset"] == 12345
  112. assert data["calibration_factor"] == pytest.approx(0.0042)
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_list_devices_empty(self, async_client: AsyncClient):
  116. resp = await async_client.get(f"{API}/devices")
  117. assert resp.status_code == 200
  118. assert resp.json() == []
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_list_devices(self, async_client: AsyncClient, device_factory):
  122. await device_factory(device_id="sb-a", hostname="alpha")
  123. await device_factory(device_id="sb-b", hostname="beta")
  124. resp = await async_client.get(f"{API}/devices")
  125. assert resp.status_code == 200
  126. devices = resp.json()
  127. assert len(devices) == 2
  128. hostnames = {d["hostname"] for d in devices}
  129. assert hostnames == {"alpha", "beta"}
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_unregister_device(self, async_client: AsyncClient, device_factory, db_session):
  133. await device_factory(device_id="sb-keep", hostname="keep")
  134. await device_factory(device_id="sb-drop", hostname="drop")
  135. spoolbuddy_routes._spoolbuddy_online_last_broadcast["sb-drop"] = 123.0
  136. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  137. mock_ws.broadcast = AsyncMock()
  138. resp = await async_client.delete(f"{API}/devices/sb-drop")
  139. assert resp.status_code == 200
  140. assert resp.json() == {"status": "deleted", "device_id": "sb-drop"}
  141. assert "sb-drop" not in spoolbuddy_routes._spoolbuddy_online_last_broadcast
  142. mock_ws.broadcast.assert_called_once()
  143. msg = mock_ws.broadcast.call_args[0][0]
  144. assert msg["type"] == "spoolbuddy_unregistered"
  145. assert msg["device_id"] == "sb-drop"
  146. # Other device still present
  147. resp = await async_client.get(f"{API}/devices")
  148. remaining = {d["device_id"] for d in resp.json()}
  149. assert remaining == {"sb-keep"}
  150. @pytest.mark.asyncio
  151. @pytest.mark.integration
  152. async def test_unregister_device_not_found(self, async_client: AsyncClient):
  153. resp = await async_client.delete(f"{API}/devices/sb-ghost")
  154. assert resp.status_code == 404
  155. @pytest.mark.asyncio
  156. @pytest.mark.integration
  157. async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
  158. device = await device_factory(device_id="sb-hb")
  159. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  160. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  161. mock_ws.broadcast = AsyncMock()
  162. resp = await async_client.post(
  163. f"{API}/devices/sb-hb/heartbeat",
  164. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 600},
  165. )
  166. assert resp.status_code == 200
  167. data = resp.json()
  168. assert data["tare_offset"] == device.tare_offset
  169. assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
  170. mock_ws.broadcast.assert_called_once()
  171. msg = mock_ws.broadcast.call_args[0][0]
  172. assert msg["type"] == "spoolbuddy_online"
  173. assert msg["device_id"] == "sb-hb"
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
  177. await device_factory(device_id="sb-cmd", pending_command="tare")
  178. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  179. mock_ws.broadcast = AsyncMock()
  180. resp = await async_client.post(
  181. f"{API}/devices/sb-cmd/heartbeat",
  182. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  183. )
  184. assert resp.status_code == 200
  185. assert resp.json()["pending_command"] == "tare"
  186. # Second heartbeat should have no pending command (cleared)
  187. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  188. mock_ws.broadcast = AsyncMock()
  189. resp2 = await async_client.post(
  190. f"{API}/devices/sb-cmd/heartbeat",
  191. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  192. )
  193. assert resp2.json()["pending_command"] is None
  194. @pytest.mark.asyncio
  195. @pytest.mark.integration
  196. async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
  197. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  198. mock_ws.broadcast = AsyncMock()
  199. resp = await async_client.post(
  200. f"{API}/devices/nonexistent/heartbeat",
  201. json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
  202. )
  203. assert resp.status_code == 404
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
  207. # Create device with last_seen far in the past (offline)
  208. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  209. await device_factory(
  210. device_id="sb-offline",
  211. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  212. )
  213. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  214. mock_ws.broadcast = AsyncMock()
  215. resp = await async_client.post(
  216. f"{API}/devices/sb-offline/heartbeat",
  217. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  218. )
  219. assert resp.status_code == 200
  220. # Should broadcast online since device was offline
  221. mock_ws.broadcast.assert_called_once()
  222. msg = mock_ws.broadcast.call_args[0][0]
  223. assert msg["type"] == "spoolbuddy_online"
  224. assert msg["device_id"] == "sb-offline"
  225. @pytest.mark.asyncio
  226. @pytest.mark.integration
  227. async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory):
  228. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  229. await device_factory(
  230. device_id="sb-already-online",
  231. last_seen=datetime.now(timezone.utc),
  232. )
  233. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  234. mock_ws.broadcast = AsyncMock()
  235. resp = await async_client.post(
  236. f"{API}/devices/sb-already-online/heartbeat",
  237. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 42},
  238. )
  239. assert resp.status_code == 200
  240. mock_ws.broadcast.assert_called_once()
  241. msg = mock_ws.broadcast.call_args[0][0]
  242. assert msg["type"] == "spoolbuddy_online"
  243. assert msg["device_id"] == "sb-already-online"
  244. @pytest.mark.asyncio
  245. @pytest.mark.integration
  246. async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory):
  247. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  248. await device_factory(
  249. device_id="sb-throttle",
  250. last_seen=datetime.now(timezone.utc),
  251. )
  252. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  253. mock_ws.broadcast = AsyncMock()
  254. resp1 = await async_client.post(
  255. f"{API}/devices/sb-throttle/heartbeat",
  256. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  257. )
  258. resp2 = await async_client.post(
  259. f"{API}/devices/sb-throttle/heartbeat",
  260. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 11},
  261. )
  262. assert resp1.status_code == 200
  263. assert resp2.status_code == 200
  264. mock_ws.broadcast.assert_called_once()
  265. msg = mock_ws.broadcast.call_args[0][0]
  266. assert msg["type"] == "spoolbuddy_online"
  267. assert msg["device_id"] == "sb-throttle"
  268. # ============================================================================
  269. # NFC endpoints
  270. # ============================================================================
  271. class TestNfcEndpoints:
  272. @pytest.mark.asyncio
  273. @pytest.mark.integration
  274. async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
  275. spool = await spool_factory(tag_uid="AABB1122", material="PLA")
  276. mock_spool = MagicMock()
  277. mock_spool.id = spool.id
  278. mock_spool.material = spool.material
  279. mock_spool.subtype = spool.subtype
  280. mock_spool.color_name = spool.color_name
  281. mock_spool.rgba = spool.rgba
  282. mock_spool.brand = spool.brand
  283. mock_spool.label_weight = spool.label_weight
  284. mock_spool.core_weight = spool.core_weight
  285. mock_spool.weight_used = spool.weight_used
  286. with (
  287. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  288. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  289. ):
  290. mock_ws.broadcast = AsyncMock()
  291. mock_lookup.return_value = mock_spool
  292. resp = await async_client.post(
  293. f"{API}/nfc/tag-scanned",
  294. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  295. )
  296. assert resp.status_code == 200
  297. data = resp.json()
  298. assert data["matched"] is True
  299. assert data["spool_id"] == spool.id
  300. msg = mock_ws.broadcast.call_args[0][0]
  301. assert msg["type"] == "spoolbuddy_tag_matched"
  302. assert msg["spool"]["id"] == spool.id
  303. @pytest.mark.asyncio
  304. @pytest.mark.integration
  305. async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
  306. with (
  307. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  308. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  309. ):
  310. mock_ws.broadcast = AsyncMock()
  311. mock_lookup.return_value = None
  312. resp = await async_client.post(
  313. f"{API}/nfc/tag-scanned",
  314. json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
  315. )
  316. assert resp.status_code == 200
  317. data = resp.json()
  318. assert data["matched"] is False
  319. assert data["spool_id"] is None
  320. msg = mock_ws.broadcast.call_args[0][0]
  321. assert msg["type"] == "spoolbuddy_unknown_tag"
  322. @pytest.mark.asyncio
  323. @pytest.mark.integration
  324. async def test_tag_removed(self, async_client: AsyncClient):
  325. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  326. mock_ws.broadcast = AsyncMock()
  327. resp = await async_client.post(
  328. f"{API}/nfc/tag-removed",
  329. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  330. )
  331. assert resp.status_code == 200
  332. msg = mock_ws.broadcast.call_args[0][0]
  333. assert msg["type"] == "spoolbuddy_tag_removed"
  334. assert msg["device_id"] == "sb-1"
  335. assert msg["tag_uid"] == "AABB1122"
  336. # ============================================================================
  337. # NFC write-tag endpoints
  338. # ============================================================================
  339. class TestWriteTagEndpoints:
  340. @pytest.mark.asyncio
  341. @pytest.mark.integration
  342. async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
  343. device = await device_factory(device_id="sb-wt")
  344. spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
  345. resp = await async_client.post(
  346. f"{API}/nfc/write-tag",
  347. json={"device_id": device.device_id, "spool_id": spool.id},
  348. )
  349. assert resp.status_code == 200
  350. assert resp.json()["status"] == "queued"
  351. # Verify heartbeat returns write_tag command with payload
  352. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  353. mock_ws.broadcast = AsyncMock()
  354. hb = await async_client.post(
  355. f"{API}/devices/{device.device_id}/heartbeat",
  356. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  357. )
  358. hb_data = hb.json()
  359. assert hb_data["pending_command"] == "write_tag"
  360. assert hb_data["pending_write_payload"] is not None
  361. assert hb_data["pending_write_payload"]["spool_id"] == spool.id
  362. assert "ndef_data_hex" in hb_data["pending_write_payload"]
  363. @pytest.mark.asyncio
  364. @pytest.mark.integration
  365. async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
  366. """write_tag command persists across heartbeats until write-result clears it."""
  367. device = await device_factory(device_id="sb-wt-persist")
  368. spool = await spool_factory(material="PETG")
  369. await async_client.post(
  370. f"{API}/nfc/write-tag",
  371. json={"device_id": device.device_id, "spool_id": spool.id},
  372. )
  373. # First heartbeat — command present
  374. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  375. mock_ws.broadcast = AsyncMock()
  376. hb1 = await async_client.post(
  377. f"{API}/devices/{device.device_id}/heartbeat",
  378. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  379. )
  380. assert hb1.json()["pending_command"] == "write_tag"
  381. # Second heartbeat — should still be present (not cleared like tare)
  382. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  383. mock_ws.broadcast = AsyncMock()
  384. hb2 = await async_client.post(
  385. f"{API}/devices/{device.device_id}/heartbeat",
  386. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  387. )
  388. assert hb2.json()["pending_command"] == "write_tag"
  389. @pytest.mark.asyncio
  390. @pytest.mark.integration
  391. async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
  392. device = await device_factory(device_id="sb-wt-nospool")
  393. resp = await async_client.post(
  394. f"{API}/nfc/write-tag",
  395. json={"device_id": device.device_id, "spool_id": 99999},
  396. )
  397. assert resp.status_code == 404
  398. @pytest.mark.asyncio
  399. @pytest.mark.integration
  400. async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
  401. spool = await spool_factory()
  402. resp = await async_client.post(
  403. f"{API}/nfc/write-tag",
  404. json={"device_id": "nonexistent", "spool_id": spool.id},
  405. )
  406. assert resp.status_code == 404
  407. @pytest.mark.asyncio
  408. @pytest.mark.integration
  409. async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
  410. device = await device_factory(device_id="sb-wr", pending_command="write_tag")
  411. spool = await spool_factory(material="PLA", tag_uid=None)
  412. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  413. mock_ws.broadcast = AsyncMock()
  414. resp = await async_client.post(
  415. f"{API}/nfc/write-result",
  416. json={
  417. "device_id": device.device_id,
  418. "spool_id": spool.id,
  419. "tag_uid": "04AABB11223344",
  420. "success": True,
  421. },
  422. )
  423. assert resp.status_code == 200
  424. msg = mock_ws.broadcast.call_args[0][0]
  425. assert msg["type"] == "spoolbuddy_tag_written"
  426. assert msg["spool_id"] == spool.id
  427. assert msg["tag_uid"] == "04AABB11223344"
  428. # Verify spool got tag linked
  429. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  430. spool_data = spool_resp.json()
  431. assert spool_data["tag_uid"] == "04AABB11223344"
  432. assert spool_data["tag_type"] == "ntag"
  433. assert spool_data["data_origin"] == "opentag3d"
  434. assert spool_data["encode_time"] is not None
  435. @pytest.mark.asyncio
  436. @pytest.mark.integration
  437. async def test_write_result_failure_broadcasts_error(
  438. self, async_client: AsyncClient, device_factory, spool_factory
  439. ):
  440. device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
  441. spool = await spool_factory(material="PLA", tag_uid=None)
  442. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  443. mock_ws.broadcast = AsyncMock()
  444. resp = await async_client.post(
  445. f"{API}/nfc/write-result",
  446. json={
  447. "device_id": device.device_id,
  448. "spool_id": spool.id,
  449. "tag_uid": "04AABB",
  450. "success": False,
  451. "message": "Write or verification failed",
  452. },
  453. )
  454. assert resp.status_code == 200
  455. msg = mock_ws.broadcast.call_args[0][0]
  456. assert msg["type"] == "spoolbuddy_tag_write_failed"
  457. assert msg["message"] == "Write or verification failed"
  458. # Verify spool NOT linked
  459. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  460. assert spool_resp.json()["tag_uid"] is None
  461. @pytest.mark.asyncio
  462. @pytest.mark.integration
  463. async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
  464. device = await device_factory(
  465. device_id="sb-wr-clear",
  466. pending_command="write_tag",
  467. pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
  468. )
  469. spool = await spool_factory()
  470. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  471. mock_ws.broadcast = AsyncMock()
  472. await async_client.post(
  473. f"{API}/nfc/write-result",
  474. json={
  475. "device_id": device.device_id,
  476. "spool_id": spool.id,
  477. "tag_uid": "AABB",
  478. "success": True,
  479. },
  480. )
  481. # Heartbeat should have no pending command
  482. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  483. mock_ws.broadcast = AsyncMock()
  484. hb = await async_client.post(
  485. f"{API}/devices/{device.device_id}/heartbeat",
  486. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
  487. )
  488. assert hb.json()["pending_command"] is None
  489. assert hb.json()["pending_write_payload"] is None
  490. @pytest.mark.asyncio
  491. @pytest.mark.integration
  492. async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
  493. device = await device_factory(device_id="sb-cancel")
  494. spool = await spool_factory()
  495. # Queue a write
  496. await async_client.post(
  497. f"{API}/nfc/write-tag",
  498. json={"device_id": device.device_id, "spool_id": spool.id},
  499. )
  500. # Cancel it
  501. resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
  502. assert resp.status_code == 200
  503. # Heartbeat should have no pending command
  504. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  505. mock_ws.broadcast = AsyncMock()
  506. hb = await async_client.post(
  507. f"{API}/devices/{device.device_id}/heartbeat",
  508. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  509. )
  510. assert hb.json()["pending_command"] is None
  511. @pytest.mark.asyncio
  512. @pytest.mark.integration
  513. async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
  514. resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
  515. assert resp.status_code == 404
  516. @pytest.mark.asyncio
  517. @pytest.mark.integration
  518. async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
  519. """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
  520. device = await device_factory(device_id="sb-wt-ndef")
  521. spool = await spool_factory(
  522. material="PLA",
  523. brand="Polymaker",
  524. color_name="White",
  525. rgba="FFFFFFFF",
  526. label_weight=1000,
  527. )
  528. await async_client.post(
  529. f"{API}/nfc/write-tag",
  530. json={"device_id": device.device_id, "spool_id": spool.id},
  531. )
  532. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  533. mock_ws.broadcast = AsyncMock()
  534. hb = await async_client.post(
  535. f"{API}/devices/{device.device_id}/heartbeat",
  536. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  537. )
  538. payload = hb.json()["pending_write_payload"]
  539. ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
  540. # CC bytes
  541. assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
  542. # TLV type
  543. assert ndef_bytes[4] == 0x03
  544. # NDEF record: TNF=MIME, type=application/opentag3d
  545. assert ndef_bytes[6] == 0xD2
  546. assert ndef_bytes[9:30] == b"application/opentag3d"
  547. # Terminator
  548. assert ndef_bytes[-1] == 0xFE
  549. # Total size fits NTAG213
  550. assert len(ndef_bytes) <= 144
  551. # ============================================================================
  552. # Scale endpoints
  553. # ============================================================================
  554. class TestScaleEndpoints:
  555. @pytest.mark.asyncio
  556. @pytest.mark.integration
  557. async def test_scale_reading_broadcast(self, async_client: AsyncClient):
  558. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  559. mock_ws.broadcast = AsyncMock()
  560. resp = await async_client.post(
  561. f"{API}/scale/reading",
  562. json={
  563. "device_id": "sb-1",
  564. "weight_grams": 823.5,
  565. "stable": True,
  566. "raw_adc": 456789,
  567. },
  568. )
  569. assert resp.status_code == 200
  570. msg = mock_ws.broadcast.call_args[0][0]
  571. assert msg["type"] == "spoolbuddy_weight"
  572. assert msg["device_id"] == "sb-1"
  573. assert msg["weight_grams"] == 823.5
  574. assert msg["stable"] is True
  575. assert msg["raw_adc"] == 456789
  576. @pytest.mark.asyncio
  577. @pytest.mark.integration
  578. async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
  579. # label=1000g, core=250g, scale reads 750g
  580. # net_filament = max(0, 750 - 250) = 500
  581. # weight_used = max(0, 1000 - 500) = 500
  582. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  583. resp = await async_client.post(
  584. f"{API}/scale/update-spool-weight",
  585. json={"spool_id": spool.id, "weight_grams": 750},
  586. )
  587. assert resp.status_code == 200
  588. data = resp.json()
  589. assert data["weight_used"] == 500
  590. @pytest.mark.asyncio
  591. @pytest.mark.integration
  592. async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
  593. # label=1000g, core=250g, scale reads 1250g (full spool)
  594. # net_filament = max(0, 1250 - 250) = 1000
  595. # weight_used = max(0, 1000 - 1000) = 0
  596. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
  597. resp = await async_client.post(
  598. f"{API}/scale/update-spool-weight",
  599. json={"spool_id": spool.id, "weight_grams": 1250},
  600. )
  601. assert resp.status_code == 200
  602. data = resp.json()
  603. assert data["weight_used"] == 0
  604. @pytest.mark.asyncio
  605. @pytest.mark.integration
  606. async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
  607. """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
  608. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  609. resp = await async_client.post(
  610. f"{API}/scale/update-spool-weight",
  611. json={"spool_id": spool.id, "weight_grams": 750},
  612. )
  613. assert resp.status_code == 200
  614. # Fetch the spool via inventory API to verify stored fields
  615. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  616. assert spool_resp.status_code == 200
  617. spool_data = spool_resp.json()
  618. assert spool_data["last_scale_weight"] == 750
  619. assert spool_data["last_weighed_at"] is not None
  620. @pytest.mark.asyncio
  621. @pytest.mark.integration
  622. async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
  623. resp = await async_client.post(
  624. f"{API}/scale/update-spool-weight",
  625. json={"spool_id": 99999, "weight_grams": 500},
  626. )
  627. assert resp.status_code == 404
  628. # ============================================================================
  629. # Calibration endpoints
  630. # ============================================================================
  631. class TestCalibrationEndpoints:
  632. @pytest.mark.asyncio
  633. @pytest.mark.integration
  634. async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
  635. await device_factory(device_id="sb-tare")
  636. resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
  637. assert resp.status_code == 200
  638. assert resp.json()["status"] == "ok"
  639. # Verify pending_command via heartbeat
  640. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  641. mock_ws.broadcast = AsyncMock()
  642. hb = await async_client.post(
  643. f"{API}/devices/sb-tare/heartbeat",
  644. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
  645. )
  646. assert hb.json()["pending_command"] == "tare"
  647. @pytest.mark.asyncio
  648. @pytest.mark.integration
  649. async def test_tare_unknown_device_404(self, async_client: AsyncClient):
  650. resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
  651. assert resp.status_code == 404
  652. @pytest.mark.asyncio
  653. @pytest.mark.integration
  654. async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
  655. await device_factory(device_id="sb-st", calibration_factor=0.005)
  656. resp = await async_client.post(
  657. f"{API}/devices/sb-st/calibration/set-tare",
  658. json={"tare_offset": 54321},
  659. )
  660. assert resp.status_code == 200
  661. data = resp.json()
  662. assert data["tare_offset"] == 54321
  663. assert data["calibration_factor"] == pytest.approx(0.005)
  664. @pytest.mark.asyncio
  665. @pytest.mark.integration
  666. async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
  667. # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
  668. await device_factory(device_id="sb-cf", tare_offset=10000)
  669. resp = await async_client.post(
  670. f"{API}/devices/sb-cf/calibration/set-factor",
  671. json={"known_weight_grams": 200, "raw_adc": 50000},
  672. )
  673. assert resp.status_code == 200
  674. data = resp.json()
  675. assert data["calibration_factor"] == pytest.approx(0.005)
  676. assert data["tare_offset"] == 10000
  677. @pytest.mark.asyncio
  678. @pytest.mark.integration
  679. async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
  680. # raw_adc == tare_offset → delta is 0 → 400 error
  681. await device_factory(device_id="sb-zero", tare_offset=5000)
  682. resp = await async_client.post(
  683. f"{API}/devices/sb-zero/calibration/set-factor",
  684. json={"known_weight_grams": 100, "raw_adc": 5000},
  685. )
  686. assert resp.status_code == 400
  687. @pytest.mark.asyncio
  688. @pytest.mark.integration
  689. async def test_get_calibration(self, async_client: AsyncClient, device_factory):
  690. await device_factory(
  691. device_id="sb-gcal",
  692. tare_offset=11111,
  693. calibration_factor=0.0042,
  694. )
  695. resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
  696. assert resp.status_code == 200
  697. data = resp.json()
  698. assert data["tare_offset"] == 11111
  699. assert data["calibration_factor"] == pytest.approx(0.0042)
  700. # ============================================================================
  701. # Display endpoints
  702. # ============================================================================
  703. class TestDisplayEndpoints:
  704. @pytest.mark.asyncio
  705. @pytest.mark.integration
  706. async def test_update_display_settings(self, async_client: AsyncClient, device_factory):
  707. await device_factory(device_id="sb-disp", display_brightness=100, display_blank_timeout=0)
  708. resp = await async_client.put(
  709. f"{API}/devices/sb-disp/display",
  710. json={"brightness": 75, "blank_timeout": 300},
  711. )
  712. assert resp.status_code == 200
  713. data = resp.json()
  714. assert data["brightness"] == 75
  715. assert data["blank_timeout"] == 300
  716. @pytest.mark.asyncio
  717. @pytest.mark.integration
  718. async def test_update_display_persists_via_heartbeat(self, async_client: AsyncClient, device_factory):
  719. await device_factory(device_id="sb-disp-hb")
  720. await async_client.put(
  721. f"{API}/devices/sb-disp-hb/display",
  722. json={"brightness": 50, "blank_timeout": 600},
  723. )
  724. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  725. mock_ws.broadcast = AsyncMock()
  726. hb = await async_client.post(
  727. f"{API}/devices/sb-disp-hb/heartbeat",
  728. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  729. )
  730. assert hb.json()["display_brightness"] == 50
  731. assert hb.json()["display_blank_timeout"] == 600
  732. @pytest.mark.asyncio
  733. @pytest.mark.integration
  734. async def test_update_display_unknown_device_404(self, async_client: AsyncClient):
  735. resp = await async_client.put(
  736. f"{API}/devices/ghost/display",
  737. json={"brightness": 50, "blank_timeout": 60},
  738. )
  739. assert resp.status_code == 404
  740. @pytest.mark.asyncio
  741. @pytest.mark.integration
  742. async def test_update_display_validates_brightness(self, async_client: AsyncClient, device_factory):
  743. await device_factory(device_id="sb-disp-val")
  744. resp = await async_client.put(
  745. f"{API}/devices/sb-disp-val/display",
  746. json={"brightness": 150, "blank_timeout": 0},
  747. )
  748. assert resp.status_code == 422 # Validation error: brightness > 100
  749. # ============================================================================
  750. # Update endpoints
  751. # ============================================================================
  752. class TestUpdateEndpoints:
  753. @pytest.mark.asyncio
  754. @pytest.mark.integration
  755. async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):
  756. await device_factory(device_id="sb-upd")
  757. with (
  758. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  759. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  760. ):
  761. mock_ws.broadcast = AsyncMock()
  762. resp = await async_client.post(f"{API}/devices/sb-upd/update")
  763. assert resp.status_code == 200
  764. assert resp.json()["status"] == "ok"
  765. @pytest.mark.asyncio
  766. @pytest.mark.integration
  767. async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
  768. await device_factory(
  769. device_id="sb-upd-off",
  770. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  771. )
  772. resp = await async_client.post(f"{API}/devices/sb-upd-off/update")
  773. assert resp.status_code == 409
  774. @pytest.mark.asyncio
  775. @pytest.mark.integration
  776. async def test_trigger_update_unknown_device_404(self, async_client: AsyncClient):
  777. resp = await async_client.post(f"{API}/devices/ghost/update")
  778. assert resp.status_code == 404
  779. @pytest.mark.asyncio
  780. @pytest.mark.integration
  781. async def test_trigger_update_already_updating(self, async_client: AsyncClient, device_factory):
  782. await device_factory(device_id="sb-upd-dup", update_status="updating")
  783. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  784. mock_ws.broadcast = AsyncMock()
  785. resp = await async_client.post(f"{API}/devices/sb-upd-dup/update")
  786. assert resp.status_code == 200
  787. assert resp.json()["status"] == "already_updating"
  788. @pytest.mark.asyncio
  789. @pytest.mark.integration
  790. async def test_report_update_status_updating(self, async_client: AsyncClient, device_factory):
  791. await device_factory(device_id="sb-upd-st", pending_command="update", update_status="pending")
  792. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  793. mock_ws.broadcast = AsyncMock()
  794. resp = await async_client.post(
  795. f"{API}/devices/sb-upd-st/update-status",
  796. json={"status": "updating", "message": "Fetching latest code..."},
  797. )
  798. assert resp.status_code == 200
  799. mock_ws.broadcast.assert_called_once()
  800. msg = mock_ws.broadcast.call_args[0][0]
  801. assert msg["type"] == "spoolbuddy_update"
  802. assert msg["update_status"] == "updating"
  803. @pytest.mark.asyncio
  804. @pytest.mark.integration
  805. async def test_report_update_status_complete_clears_command(self, async_client: AsyncClient, device_factory):
  806. await device_factory(device_id="sb-upd-done", pending_command="update", update_status="updating")
  807. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  808. mock_ws.broadcast = AsyncMock()
  809. await async_client.post(
  810. f"{API}/devices/sb-upd-done/update-status",
  811. json={"status": "complete", "message": "Update complete, restarting..."},
  812. )
  813. # Heartbeat should have no pending command
  814. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  815. mock_ws.broadcast = AsyncMock()
  816. hb = await async_client.post(
  817. f"{API}/devices/sb-upd-done/heartbeat",
  818. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  819. )
  820. assert hb.json()["pending_command"] is None
  821. @pytest.mark.asyncio
  822. @pytest.mark.integration
  823. async def test_report_update_status_error(self, async_client: AsyncClient, device_factory):
  824. await device_factory(device_id="sb-upd-err", pending_command="update", update_status="updating")
  825. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  826. mock_ws.broadcast = AsyncMock()
  827. resp = await async_client.post(
  828. f"{API}/devices/sb-upd-err/update-status",
  829. json={"status": "error", "message": "git fetch failed: network unreachable"},
  830. )
  831. assert resp.status_code == 200
  832. msg = mock_ws.broadcast.call_args[0][0]
  833. assert msg["update_status"] == "error"
  834. assert "git fetch failed" in msg["update_message"]
  835. @pytest.mark.asyncio
  836. @pytest.mark.integration
  837. async def test_report_update_status_unknown_device_404(self, async_client: AsyncClient):
  838. resp = await async_client.post(
  839. f"{API}/devices/ghost/update-status",
  840. json={"status": "updating", "message": "test"},
  841. )
  842. assert resp.status_code == 404
  843. @pytest.mark.asyncio
  844. @pytest.mark.integration
  845. async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
  846. await device_factory(device_id="sb-upd-resp", update_status="complete", update_message="Done!")
  847. resp = await async_client.get(f"{API}/devices")
  848. assert resp.status_code == 200
  849. device = next(d for d in resp.json() if d["device_id"] == "sb-upd-resp")
  850. assert device["update_status"] == "complete"
  851. assert device["update_message"] == "Done!"
  852. @pytest.mark.asyncio
  853. @pytest.mark.integration
  854. async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
  855. """GET /devices/{id}/update-check compares device version against APP_VERSION."""
  856. await device_factory(device_id="sb-uc", firmware_version="0.1.0")
  857. resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
  858. assert resp.status_code == 200
  859. data = resp.json()
  860. assert data["current_version"] == "0.1.0"
  861. assert data["latest_version"] is not None
  862. assert data["update_available"] is True
  863. @pytest.mark.asyncio
  864. @pytest.mark.integration
  865. async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
  866. from backend.app.core.config import APP_VERSION
  867. await device_factory(device_id="sb-uc2", firmware_version=APP_VERSION)
  868. resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
  869. assert resp.status_code == 200
  870. assert resp.json()["update_available"] is False
  871. @pytest.mark.asyncio
  872. @pytest.mark.integration
  873. async def test_update_check_unknown_device_404(self, async_client: AsyncClient):
  874. resp = await async_client.get(f"{API}/devices/ghost/update-check")
  875. assert resp.status_code == 404
  876. @pytest.mark.asyncio
  877. @pytest.mark.integration
  878. async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
  879. await device_factory(device_id="sb-upd-ws")
  880. with (
  881. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  882. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  883. ):
  884. mock_ws.broadcast = AsyncMock()
  885. await async_client.post(f"{API}/devices/sb-upd-ws/update")
  886. mock_ws.broadcast.assert_called_once()
  887. msg = mock_ws.broadcast.call_args[0][0]
  888. assert msg["type"] == "spoolbuddy_update"
  889. assert msg["device_id"] == "sb-upd-ws"
  890. assert msg["update_status"] == "pending"
  891. # ============================================================================
  892. # System command endpoints
  893. # ============================================================================
  894. class TestSystemCommandEndpoints:
  895. @pytest.mark.asyncio
  896. @pytest.mark.integration
  897. async def test_queue_reboot(self, async_client: AsyncClient, device_factory):
  898. await device_factory(device_id="sb-reboot")
  899. resp = await async_client.post(
  900. f"{API}/devices/sb-reboot/system/command",
  901. json={"command": "reboot"},
  902. )
  903. assert resp.status_code == 200
  904. data = resp.json()
  905. assert data["status"] == "queued"
  906. assert data["command"] == "reboot"
  907. @pytest.mark.asyncio
  908. @pytest.mark.integration
  909. async def test_queue_shutdown(self, async_client: AsyncClient, device_factory):
  910. await device_factory(device_id="sb-shutdown")
  911. resp = await async_client.post(
  912. f"{API}/devices/sb-shutdown/system/command",
  913. json={"command": "shutdown"},
  914. )
  915. assert resp.status_code == 200
  916. assert resp.json()["command"] == "shutdown"
  917. @pytest.mark.asyncio
  918. @pytest.mark.integration
  919. async def test_queue_restart_daemon(self, async_client: AsyncClient, device_factory):
  920. await device_factory(device_id="sb-rd")
  921. resp = await async_client.post(
  922. f"{API}/devices/sb-rd/system/command",
  923. json={"command": "restart_daemon"},
  924. )
  925. assert resp.status_code == 200
  926. assert resp.json()["command"] == "restart_daemon"
  927. @pytest.mark.asyncio
  928. @pytest.mark.integration
  929. async def test_queue_restart_browser(self, async_client: AsyncClient, device_factory):
  930. await device_factory(device_id="sb-rb")
  931. resp = await async_client.post(
  932. f"{API}/devices/sb-rb/system/command",
  933. json={"command": "restart_browser"},
  934. )
  935. assert resp.status_code == 200
  936. assert resp.json()["command"] == "restart_browser"
  937. @pytest.mark.asyncio
  938. @pytest.mark.integration
  939. async def test_invalid_command_rejected(self, async_client: AsyncClient, device_factory):
  940. await device_factory(device_id="sb-invalid")
  941. resp = await async_client.post(
  942. f"{API}/devices/sb-invalid/system/command",
  943. json={"command": "format_disk"},
  944. )
  945. assert resp.status_code == 400
  946. assert "Invalid command" in resp.json()["detail"]
  947. @pytest.mark.asyncio
  948. @pytest.mark.integration
  949. async def test_command_unknown_device_404(self, async_client: AsyncClient):
  950. resp = await async_client.post(
  951. f"{API}/devices/ghost/system/command",
  952. json={"command": "reboot"},
  953. )
  954. assert resp.status_code == 404
  955. @pytest.mark.asyncio
  956. @pytest.mark.integration
  957. async def test_command_offline_device_409(self, async_client: AsyncClient, device_factory):
  958. await device_factory(
  959. device_id="sb-offline-cmd",
  960. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  961. )
  962. resp = await async_client.post(
  963. f"{API}/devices/sb-offline-cmd/system/command",
  964. json={"command": "reboot"},
  965. )
  966. assert resp.status_code == 409
  967. assert "offline" in resp.json()["detail"].lower()
  968. @pytest.mark.asyncio
  969. @pytest.mark.integration
  970. async def test_command_sets_pending_command(self, async_client: AsyncClient, device_factory, db_session):
  971. device = await device_factory(device_id="sb-pending")
  972. await async_client.post(
  973. f"{API}/devices/sb-pending/system/command",
  974. json={"command": "restart_daemon"},
  975. )
  976. await db_session.refresh(device)
  977. assert device.pending_command == "restart_daemon"
  978. @pytest.mark.asyncio
  979. @pytest.mark.integration
  980. async def test_heartbeat_clears_system_command(self, async_client: AsyncClient, device_factory):
  981. """System commands (reboot/shutdown/restart_*) are fire-and-forget — heartbeat clears them."""
  982. await device_factory(device_id="sb-hb-clear")
  983. # Queue a command
  984. await async_client.post(
  985. f"{API}/devices/sb-hb-clear/system/command",
  986. json={"command": "restart_browser"},
  987. )
  988. # Heartbeat should return the command and clear it
  989. resp = await async_client.post(
  990. f"{API}/devices/sb-hb-clear/heartbeat",
  991. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 100},
  992. )
  993. assert resp.status_code == 200
  994. data = resp.json()
  995. assert data["pending_command"] == "restart_browser"