test_spoolbuddy.py 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265
  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_ssh_public_key(self, async_client: AsyncClient, device_factory):
  177. """Heartbeat response carries the current SSH public key so the daemon
  178. can re-deploy it whenever Bambuddy's keypair rotates without waiting
  179. for a service restart."""
  180. await device_factory(device_id="sb-ssh-hb")
  181. fake_key = "ssh-ed25519 AAAATESTKEY bambuddy-spoolbuddy"
  182. with (
  183. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  184. patch(
  185. "backend.app.services.spoolbuddy_ssh.get_public_key",
  186. AsyncMock(return_value=fake_key),
  187. ),
  188. ):
  189. mock_ws.broadcast = AsyncMock()
  190. resp = await async_client.post(
  191. f"{API}/devices/sb-ssh-hb/heartbeat",
  192. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  193. )
  194. assert resp.status_code == 200
  195. assert resp.json()["ssh_public_key"] == fake_key
  196. @pytest.mark.asyncio
  197. @pytest.mark.integration
  198. async def test_heartbeat_ssh_key_failure_does_not_break_heartbeat(self, async_client: AsyncClient, device_factory):
  199. """If the backend can't read its own SSH key, the heartbeat must still
  200. succeed — telemetry/commands are far more critical than key sync."""
  201. await device_factory(device_id="sb-ssh-fail")
  202. with (
  203. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  204. patch(
  205. "backend.app.services.spoolbuddy_ssh.get_public_key",
  206. AsyncMock(side_effect=OSError("disk full")),
  207. ),
  208. ):
  209. mock_ws.broadcast = AsyncMock()
  210. resp = await async_client.post(
  211. f"{API}/devices/sb-ssh-fail/heartbeat",
  212. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  213. )
  214. assert resp.status_code == 200
  215. assert resp.json()["ssh_public_key"] is None
  216. @pytest.mark.asyncio
  217. @pytest.mark.integration
  218. async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
  219. await device_factory(device_id="sb-cmd", pending_command="tare")
  220. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  221. mock_ws.broadcast = AsyncMock()
  222. resp = await async_client.post(
  223. f"{API}/devices/sb-cmd/heartbeat",
  224. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  225. )
  226. assert resp.status_code == 200
  227. assert resp.json()["pending_command"] == "tare"
  228. # Second heartbeat should have no pending command (cleared)
  229. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  230. mock_ws.broadcast = AsyncMock()
  231. resp2 = await async_client.post(
  232. f"{API}/devices/sb-cmd/heartbeat",
  233. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  234. )
  235. assert resp2.json()["pending_command"] is None
  236. @pytest.mark.asyncio
  237. @pytest.mark.integration
  238. async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
  239. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  240. mock_ws.broadcast = AsyncMock()
  241. resp = await async_client.post(
  242. f"{API}/devices/nonexistent/heartbeat",
  243. json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
  244. )
  245. assert resp.status_code == 404
  246. @pytest.mark.asyncio
  247. @pytest.mark.integration
  248. async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
  249. # Create device with last_seen far in the past (offline)
  250. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  251. await device_factory(
  252. device_id="sb-offline",
  253. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  254. )
  255. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  256. mock_ws.broadcast = AsyncMock()
  257. resp = await async_client.post(
  258. f"{API}/devices/sb-offline/heartbeat",
  259. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  260. )
  261. assert resp.status_code == 200
  262. # Should broadcast online since device was offline
  263. mock_ws.broadcast.assert_called_once()
  264. msg = mock_ws.broadcast.call_args[0][0]
  265. assert msg["type"] == "spoolbuddy_online"
  266. assert msg["device_id"] == "sb-offline"
  267. @pytest.mark.asyncio
  268. @pytest.mark.integration
  269. async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory):
  270. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  271. await device_factory(
  272. device_id="sb-already-online",
  273. last_seen=datetime.now(timezone.utc),
  274. )
  275. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  276. mock_ws.broadcast = AsyncMock()
  277. resp = await async_client.post(
  278. f"{API}/devices/sb-already-online/heartbeat",
  279. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 42},
  280. )
  281. assert resp.status_code == 200
  282. mock_ws.broadcast.assert_called_once()
  283. msg = mock_ws.broadcast.call_args[0][0]
  284. assert msg["type"] == "spoolbuddy_online"
  285. assert msg["device_id"] == "sb-already-online"
  286. @pytest.mark.asyncio
  287. @pytest.mark.integration
  288. async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory):
  289. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  290. await device_factory(
  291. device_id="sb-throttle",
  292. last_seen=datetime.now(timezone.utc),
  293. )
  294. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  295. mock_ws.broadcast = AsyncMock()
  296. resp1 = await async_client.post(
  297. f"{API}/devices/sb-throttle/heartbeat",
  298. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  299. )
  300. resp2 = await async_client.post(
  301. f"{API}/devices/sb-throttle/heartbeat",
  302. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 11},
  303. )
  304. assert resp1.status_code == 200
  305. assert resp2.status_code == 200
  306. mock_ws.broadcast.assert_called_once()
  307. msg = mock_ws.broadcast.call_args[0][0]
  308. assert msg["type"] == "spoolbuddy_online"
  309. assert msg["device_id"] == "sb-throttle"
  310. # ============================================================================
  311. # NFC endpoints
  312. # ============================================================================
  313. class TestNfcEndpoints:
  314. @pytest.mark.asyncio
  315. @pytest.mark.integration
  316. async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
  317. spool = await spool_factory(tag_uid="AABB1122", material="PLA")
  318. mock_spool = MagicMock()
  319. mock_spool.id = spool.id
  320. mock_spool.material = spool.material
  321. mock_spool.subtype = spool.subtype
  322. mock_spool.color_name = spool.color_name
  323. mock_spool.rgba = spool.rgba
  324. mock_spool.brand = spool.brand
  325. mock_spool.label_weight = spool.label_weight
  326. mock_spool.core_weight = spool.core_weight
  327. mock_spool.weight_used = spool.weight_used
  328. with (
  329. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  330. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  331. ):
  332. mock_ws.broadcast = AsyncMock()
  333. mock_lookup.return_value = mock_spool
  334. resp = await async_client.post(
  335. f"{API}/nfc/tag-scanned",
  336. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  337. )
  338. assert resp.status_code == 200
  339. data = resp.json()
  340. assert data["matched"] is True
  341. assert data["spool_id"] == spool.id
  342. msg = mock_ws.broadcast.call_args[0][0]
  343. assert msg["type"] == "spoolbuddy_tag_matched"
  344. assert msg["spool"]["id"] == spool.id
  345. @pytest.mark.asyncio
  346. @pytest.mark.integration
  347. async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
  348. with (
  349. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  350. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  351. ):
  352. mock_ws.broadcast = AsyncMock()
  353. mock_lookup.return_value = None
  354. resp = await async_client.post(
  355. f"{API}/nfc/tag-scanned",
  356. json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
  357. )
  358. assert resp.status_code == 200
  359. data = resp.json()
  360. assert data["matched"] is False
  361. assert data["spool_id"] is None
  362. msg = mock_ws.broadcast.call_args[0][0]
  363. assert msg["type"] == "spoolbuddy_unknown_tag"
  364. @pytest.mark.asyncio
  365. @pytest.mark.integration
  366. async def test_tag_removed(self, async_client: AsyncClient):
  367. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  368. mock_ws.broadcast = AsyncMock()
  369. resp = await async_client.post(
  370. f"{API}/nfc/tag-removed",
  371. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  372. )
  373. assert resp.status_code == 200
  374. msg = mock_ws.broadcast.call_args[0][0]
  375. assert msg["type"] == "spoolbuddy_tag_removed"
  376. assert msg["device_id"] == "sb-1"
  377. assert msg["tag_uid"] == "AABB1122"
  378. # ============================================================================
  379. # NFC write-tag endpoints
  380. # ============================================================================
  381. class TestWriteTagEndpoints:
  382. @pytest.mark.asyncio
  383. @pytest.mark.integration
  384. async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
  385. device = await device_factory(device_id="sb-wt")
  386. spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
  387. resp = await async_client.post(
  388. f"{API}/nfc/write-tag",
  389. json={"device_id": device.device_id, "spool_id": spool.id},
  390. )
  391. assert resp.status_code == 200
  392. assert resp.json()["status"] == "queued"
  393. # Verify heartbeat returns write_tag command with payload
  394. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  395. mock_ws.broadcast = AsyncMock()
  396. hb = await async_client.post(
  397. f"{API}/devices/{device.device_id}/heartbeat",
  398. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  399. )
  400. hb_data = hb.json()
  401. assert hb_data["pending_command"] == "write_tag"
  402. assert hb_data["pending_write_payload"] is not None
  403. assert hb_data["pending_write_payload"]["spool_id"] == spool.id
  404. assert "ndef_data_hex" in hb_data["pending_write_payload"]
  405. @pytest.mark.asyncio
  406. @pytest.mark.integration
  407. async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
  408. """write_tag command persists across heartbeats until write-result clears it."""
  409. device = await device_factory(device_id="sb-wt-persist")
  410. spool = await spool_factory(material="PETG")
  411. await async_client.post(
  412. f"{API}/nfc/write-tag",
  413. json={"device_id": device.device_id, "spool_id": spool.id},
  414. )
  415. # First heartbeat — command present
  416. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  417. mock_ws.broadcast = AsyncMock()
  418. hb1 = await async_client.post(
  419. f"{API}/devices/{device.device_id}/heartbeat",
  420. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  421. )
  422. assert hb1.json()["pending_command"] == "write_tag"
  423. # Second heartbeat — should still be present (not cleared like tare)
  424. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  425. mock_ws.broadcast = AsyncMock()
  426. hb2 = await async_client.post(
  427. f"{API}/devices/{device.device_id}/heartbeat",
  428. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  429. )
  430. assert hb2.json()["pending_command"] == "write_tag"
  431. @pytest.mark.asyncio
  432. @pytest.mark.integration
  433. async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
  434. device = await device_factory(device_id="sb-wt-nospool")
  435. resp = await async_client.post(
  436. f"{API}/nfc/write-tag",
  437. json={"device_id": device.device_id, "spool_id": 99999},
  438. )
  439. assert resp.status_code == 404
  440. @pytest.mark.asyncio
  441. @pytest.mark.integration
  442. async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
  443. spool = await spool_factory()
  444. resp = await async_client.post(
  445. f"{API}/nfc/write-tag",
  446. json={"device_id": "nonexistent", "spool_id": spool.id},
  447. )
  448. assert resp.status_code == 404
  449. @pytest.mark.asyncio
  450. @pytest.mark.integration
  451. async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
  452. device = await device_factory(device_id="sb-wr", pending_command="write_tag")
  453. spool = await spool_factory(material="PLA", tag_uid=None)
  454. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  455. mock_ws.broadcast = AsyncMock()
  456. resp = await async_client.post(
  457. f"{API}/nfc/write-result",
  458. json={
  459. "device_id": device.device_id,
  460. "spool_id": spool.id,
  461. "tag_uid": "04AABB11223344",
  462. "success": True,
  463. },
  464. )
  465. assert resp.status_code == 200
  466. msg = mock_ws.broadcast.call_args[0][0]
  467. assert msg["type"] == "spoolbuddy_tag_written"
  468. assert msg["spool_id"] == spool.id
  469. assert msg["tag_uid"] == "04AABB11223344"
  470. # Verify spool got tag linked
  471. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  472. spool_data = spool_resp.json()
  473. assert spool_data["tag_uid"] == "04AABB11223344"
  474. assert spool_data["tag_type"] == "ntag"
  475. assert spool_data["data_origin"] == "opentag3d"
  476. assert spool_data["encode_time"] is not None
  477. @pytest.mark.asyncio
  478. @pytest.mark.integration
  479. async def test_write_result_failure_broadcasts_error(
  480. self, async_client: AsyncClient, device_factory, spool_factory
  481. ):
  482. device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
  483. spool = await spool_factory(material="PLA", tag_uid=None)
  484. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  485. mock_ws.broadcast = AsyncMock()
  486. resp = await async_client.post(
  487. f"{API}/nfc/write-result",
  488. json={
  489. "device_id": device.device_id,
  490. "spool_id": spool.id,
  491. "tag_uid": "04AABB",
  492. "success": False,
  493. "message": "Write or verification failed",
  494. },
  495. )
  496. assert resp.status_code == 200
  497. msg = mock_ws.broadcast.call_args[0][0]
  498. assert msg["type"] == "spoolbuddy_tag_write_failed"
  499. assert msg["message"] == "Write or verification failed"
  500. # Verify spool NOT linked
  501. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  502. assert spool_resp.json()["tag_uid"] is None
  503. @pytest.mark.asyncio
  504. @pytest.mark.integration
  505. async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
  506. device = await device_factory(
  507. device_id="sb-wr-clear",
  508. pending_command="write_tag",
  509. pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
  510. )
  511. spool = await spool_factory()
  512. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  513. mock_ws.broadcast = AsyncMock()
  514. await async_client.post(
  515. f"{API}/nfc/write-result",
  516. json={
  517. "device_id": device.device_id,
  518. "spool_id": spool.id,
  519. "tag_uid": "AABB",
  520. "success": True,
  521. },
  522. )
  523. # Heartbeat should have no pending command
  524. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  525. mock_ws.broadcast = AsyncMock()
  526. hb = await async_client.post(
  527. f"{API}/devices/{device.device_id}/heartbeat",
  528. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
  529. )
  530. assert hb.json()["pending_command"] is None
  531. assert hb.json()["pending_write_payload"] is None
  532. @pytest.mark.asyncio
  533. @pytest.mark.integration
  534. async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
  535. device = await device_factory(device_id="sb-cancel")
  536. spool = await spool_factory()
  537. # Queue a write
  538. await async_client.post(
  539. f"{API}/nfc/write-tag",
  540. json={"device_id": device.device_id, "spool_id": spool.id},
  541. )
  542. # Cancel it
  543. resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
  544. assert resp.status_code == 200
  545. # Heartbeat should have no pending command
  546. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  547. mock_ws.broadcast = AsyncMock()
  548. hb = await async_client.post(
  549. f"{API}/devices/{device.device_id}/heartbeat",
  550. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  551. )
  552. assert hb.json()["pending_command"] is None
  553. @pytest.mark.asyncio
  554. @pytest.mark.integration
  555. async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
  556. resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
  557. assert resp.status_code == 404
  558. @pytest.mark.asyncio
  559. @pytest.mark.integration
  560. async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
  561. """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
  562. device = await device_factory(device_id="sb-wt-ndef")
  563. spool = await spool_factory(
  564. material="PLA",
  565. brand="Polymaker",
  566. color_name="White",
  567. rgba="FFFFFFFF",
  568. label_weight=1000,
  569. )
  570. await async_client.post(
  571. f"{API}/nfc/write-tag",
  572. json={"device_id": device.device_id, "spool_id": spool.id},
  573. )
  574. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  575. mock_ws.broadcast = AsyncMock()
  576. hb = await async_client.post(
  577. f"{API}/devices/{device.device_id}/heartbeat",
  578. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  579. )
  580. payload = hb.json()["pending_write_payload"]
  581. ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
  582. # CC bytes
  583. assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
  584. # TLV type
  585. assert ndef_bytes[4] == 0x03
  586. # NDEF record: TNF=MIME, type=application/opentag3d
  587. assert ndef_bytes[6] == 0xD2
  588. assert ndef_bytes[9:30] == b"application/opentag3d"
  589. # Terminator
  590. assert ndef_bytes[-1] == 0xFE
  591. # Total size fits NTAG213
  592. assert len(ndef_bytes) <= 144
  593. # ============================================================================
  594. # Scale endpoints
  595. # ============================================================================
  596. class TestScaleEndpoints:
  597. @pytest.mark.asyncio
  598. @pytest.mark.integration
  599. async def test_scale_reading_broadcast(self, async_client: AsyncClient):
  600. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  601. mock_ws.broadcast = AsyncMock()
  602. resp = await async_client.post(
  603. f"{API}/scale/reading",
  604. json={
  605. "device_id": "sb-1",
  606. "weight_grams": 823.5,
  607. "stable": True,
  608. "raw_adc": 456789,
  609. },
  610. )
  611. assert resp.status_code == 200
  612. msg = mock_ws.broadcast.call_args[0][0]
  613. assert msg["type"] == "spoolbuddy_weight"
  614. assert msg["device_id"] == "sb-1"
  615. assert msg["weight_grams"] == 823.5
  616. assert msg["stable"] is True
  617. assert msg["raw_adc"] == 456789
  618. @pytest.mark.asyncio
  619. @pytest.mark.integration
  620. async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
  621. # label=1000g, core=250g, scale reads 750g
  622. # net_filament = max(0, 750 - 250) = 500
  623. # weight_used = max(0, 1000 - 500) = 500
  624. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  625. resp = await async_client.post(
  626. f"{API}/scale/update-spool-weight",
  627. json={"spool_id": spool.id, "weight_grams": 750},
  628. )
  629. assert resp.status_code == 200
  630. data = resp.json()
  631. assert data["weight_used"] == 500
  632. @pytest.mark.asyncio
  633. @pytest.mark.integration
  634. async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
  635. # label=1000g, core=250g, scale reads 1250g (full spool)
  636. # net_filament = max(0, 1250 - 250) = 1000
  637. # weight_used = max(0, 1000 - 1000) = 0
  638. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
  639. resp = await async_client.post(
  640. f"{API}/scale/update-spool-weight",
  641. json={"spool_id": spool.id, "weight_grams": 1250},
  642. )
  643. assert resp.status_code == 200
  644. data = resp.json()
  645. assert data["weight_used"] == 0
  646. @pytest.mark.asyncio
  647. @pytest.mark.integration
  648. async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
  649. """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
  650. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  651. resp = await async_client.post(
  652. f"{API}/scale/update-spool-weight",
  653. json={"spool_id": spool.id, "weight_grams": 750},
  654. )
  655. assert resp.status_code == 200
  656. # Fetch the spool via inventory API to verify stored fields
  657. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  658. assert spool_resp.status_code == 200
  659. spool_data = spool_resp.json()
  660. assert spool_data["last_scale_weight"] == 750
  661. assert spool_data["last_weighed_at"] is not None
  662. @pytest.mark.asyncio
  663. @pytest.mark.integration
  664. async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
  665. resp = await async_client.post(
  666. f"{API}/scale/update-spool-weight",
  667. json={"spool_id": 99999, "weight_grams": 500},
  668. )
  669. assert resp.status_code == 404
  670. # ============================================================================
  671. # Calibration endpoints
  672. # ============================================================================
  673. class TestCalibrationEndpoints:
  674. @pytest.mark.asyncio
  675. @pytest.mark.integration
  676. async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
  677. await device_factory(device_id="sb-tare")
  678. resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
  679. assert resp.status_code == 200
  680. assert resp.json()["status"] == "ok"
  681. # Verify pending_command via heartbeat
  682. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  683. mock_ws.broadcast = AsyncMock()
  684. hb = await async_client.post(
  685. f"{API}/devices/sb-tare/heartbeat",
  686. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
  687. )
  688. assert hb.json()["pending_command"] == "tare"
  689. @pytest.mark.asyncio
  690. @pytest.mark.integration
  691. async def test_tare_unknown_device_404(self, async_client: AsyncClient):
  692. resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
  693. assert resp.status_code == 404
  694. @pytest.mark.asyncio
  695. @pytest.mark.integration
  696. async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
  697. await device_factory(device_id="sb-st", calibration_factor=0.005)
  698. resp = await async_client.post(
  699. f"{API}/devices/sb-st/calibration/set-tare",
  700. json={"tare_offset": 54321},
  701. )
  702. assert resp.status_code == 200
  703. data = resp.json()
  704. assert data["tare_offset"] == 54321
  705. assert data["calibration_factor"] == pytest.approx(0.005)
  706. @pytest.mark.asyncio
  707. @pytest.mark.integration
  708. async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
  709. # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
  710. await device_factory(device_id="sb-cf", tare_offset=10000)
  711. resp = await async_client.post(
  712. f"{API}/devices/sb-cf/calibration/set-factor",
  713. json={"known_weight_grams": 200, "raw_adc": 50000},
  714. )
  715. assert resp.status_code == 200
  716. data = resp.json()
  717. assert data["calibration_factor"] == pytest.approx(0.005)
  718. assert data["tare_offset"] == 10000
  719. @pytest.mark.asyncio
  720. @pytest.mark.integration
  721. async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
  722. # raw_adc == tare_offset → delta is 0 → 400 error
  723. await device_factory(device_id="sb-zero", tare_offset=5000)
  724. resp = await async_client.post(
  725. f"{API}/devices/sb-zero/calibration/set-factor",
  726. json={"known_weight_grams": 100, "raw_adc": 5000},
  727. )
  728. assert resp.status_code == 400
  729. @pytest.mark.asyncio
  730. @pytest.mark.integration
  731. async def test_get_calibration(self, async_client: AsyncClient, device_factory):
  732. await device_factory(
  733. device_id="sb-gcal",
  734. tare_offset=11111,
  735. calibration_factor=0.0042,
  736. )
  737. resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
  738. assert resp.status_code == 200
  739. data = resp.json()
  740. assert data["tare_offset"] == 11111
  741. assert data["calibration_factor"] == pytest.approx(0.0042)
  742. # ============================================================================
  743. # Display endpoints
  744. # ============================================================================
  745. class TestDisplayEndpoints:
  746. @pytest.mark.asyncio
  747. @pytest.mark.integration
  748. async def test_update_display_settings(self, async_client: AsyncClient, device_factory):
  749. await device_factory(device_id="sb-disp", display_brightness=100, display_blank_timeout=0)
  750. resp = await async_client.put(
  751. f"{API}/devices/sb-disp/display",
  752. json={"brightness": 75, "blank_timeout": 300},
  753. )
  754. assert resp.status_code == 200
  755. data = resp.json()
  756. assert data["brightness"] == 75
  757. assert data["blank_timeout"] == 300
  758. @pytest.mark.asyncio
  759. @pytest.mark.integration
  760. async def test_update_display_persists_via_heartbeat(self, async_client: AsyncClient, device_factory):
  761. await device_factory(device_id="sb-disp-hb")
  762. await async_client.put(
  763. f"{API}/devices/sb-disp-hb/display",
  764. json={"brightness": 50, "blank_timeout": 600},
  765. )
  766. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  767. mock_ws.broadcast = AsyncMock()
  768. hb = await async_client.post(
  769. f"{API}/devices/sb-disp-hb/heartbeat",
  770. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  771. )
  772. assert hb.json()["display_brightness"] == 50
  773. assert hb.json()["display_blank_timeout"] == 600
  774. @pytest.mark.asyncio
  775. @pytest.mark.integration
  776. async def test_update_display_unknown_device_404(self, async_client: AsyncClient):
  777. resp = await async_client.put(
  778. f"{API}/devices/ghost/display",
  779. json={"brightness": 50, "blank_timeout": 60},
  780. )
  781. assert resp.status_code == 404
  782. @pytest.mark.asyncio
  783. @pytest.mark.integration
  784. async def test_update_display_validates_brightness(self, async_client: AsyncClient, device_factory):
  785. await device_factory(device_id="sb-disp-val")
  786. resp = await async_client.put(
  787. f"{API}/devices/sb-disp-val/display",
  788. json={"brightness": 150, "blank_timeout": 0},
  789. )
  790. assert resp.status_code == 422 # Validation error: brightness > 100
  791. @pytest.mark.asyncio
  792. @pytest.mark.integration
  793. async def test_get_display_settings(self, async_client: AsyncClient, device_factory):
  794. """The kiosk idle watchdog (install/spoolbuddy-idle.sh) reads this
  795. endpoint on autostart to configure swayidle with the user-selected
  796. blank timeout before launching. See issue #937."""
  797. await device_factory(device_id="sb-disp-get", display_brightness=60, display_blank_timeout=450)
  798. resp = await async_client.get(f"{API}/devices/sb-disp-get/display")
  799. assert resp.status_code == 200
  800. data = resp.json()
  801. assert data["brightness"] == 60
  802. assert data["blank_timeout"] == 450
  803. @pytest.mark.asyncio
  804. @pytest.mark.integration
  805. async def test_get_display_unknown_device_404(self, async_client: AsyncClient):
  806. resp = await async_client.get(f"{API}/devices/ghost/display")
  807. assert resp.status_code == 404
  808. # ============================================================================
  809. # Update endpoints
  810. # ============================================================================
  811. class TestUpdateEndpoints:
  812. @pytest.mark.asyncio
  813. @pytest.mark.integration
  814. async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):
  815. await device_factory(device_id="sb-upd")
  816. with (
  817. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  818. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  819. ):
  820. mock_ws.broadcast = AsyncMock()
  821. resp = await async_client.post(f"{API}/devices/sb-upd/update")
  822. assert resp.status_code == 200
  823. assert resp.json()["status"] == "ok"
  824. @pytest.mark.asyncio
  825. @pytest.mark.integration
  826. async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
  827. await device_factory(
  828. device_id="sb-upd-off",
  829. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  830. )
  831. resp = await async_client.post(f"{API}/devices/sb-upd-off/update")
  832. assert resp.status_code == 409
  833. @pytest.mark.asyncio
  834. @pytest.mark.integration
  835. async def test_trigger_update_unknown_device_404(self, async_client: AsyncClient):
  836. resp = await async_client.post(f"{API}/devices/ghost/update")
  837. assert resp.status_code == 404
  838. @pytest.mark.asyncio
  839. @pytest.mark.integration
  840. async def test_trigger_update_already_updating(self, async_client: AsyncClient, device_factory):
  841. await device_factory(device_id="sb-upd-dup", update_status="updating")
  842. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  843. mock_ws.broadcast = AsyncMock()
  844. resp = await async_client.post(f"{API}/devices/sb-upd-dup/update")
  845. assert resp.status_code == 200
  846. assert resp.json()["status"] == "already_updating"
  847. @pytest.mark.asyncio
  848. @pytest.mark.integration
  849. async def test_report_update_status_updating(self, async_client: AsyncClient, device_factory):
  850. await device_factory(device_id="sb-upd-st", pending_command="update", update_status="pending")
  851. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  852. mock_ws.broadcast = AsyncMock()
  853. resp = await async_client.post(
  854. f"{API}/devices/sb-upd-st/update-status",
  855. json={"status": "updating", "message": "Fetching latest code..."},
  856. )
  857. assert resp.status_code == 200
  858. mock_ws.broadcast.assert_called_once()
  859. msg = mock_ws.broadcast.call_args[0][0]
  860. assert msg["type"] == "spoolbuddy_update"
  861. assert msg["update_status"] == "updating"
  862. @pytest.mark.asyncio
  863. @pytest.mark.integration
  864. async def test_report_update_status_complete_clears_command(self, async_client: AsyncClient, device_factory):
  865. await device_factory(device_id="sb-upd-done", pending_command="update", update_status="updating")
  866. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  867. mock_ws.broadcast = AsyncMock()
  868. await async_client.post(
  869. f"{API}/devices/sb-upd-done/update-status",
  870. json={"status": "complete", "message": "Update complete, restarting..."},
  871. )
  872. # Heartbeat should have no pending command
  873. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  874. mock_ws.broadcast = AsyncMock()
  875. hb = await async_client.post(
  876. f"{API}/devices/sb-upd-done/heartbeat",
  877. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  878. )
  879. assert hb.json()["pending_command"] is None
  880. @pytest.mark.asyncio
  881. @pytest.mark.integration
  882. async def test_report_update_status_error(self, async_client: AsyncClient, device_factory):
  883. await device_factory(device_id="sb-upd-err", pending_command="update", update_status="updating")
  884. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  885. mock_ws.broadcast = AsyncMock()
  886. resp = await async_client.post(
  887. f"{API}/devices/sb-upd-err/update-status",
  888. json={"status": "error", "message": "git fetch failed: network unreachable"},
  889. )
  890. assert resp.status_code == 200
  891. msg = mock_ws.broadcast.call_args[0][0]
  892. assert msg["update_status"] == "error"
  893. assert "git fetch failed" in msg["update_message"]
  894. @pytest.mark.asyncio
  895. @pytest.mark.integration
  896. async def test_report_update_status_unknown_device_404(self, async_client: AsyncClient):
  897. resp = await async_client.post(
  898. f"{API}/devices/ghost/update-status",
  899. json={"status": "updating", "message": "test"},
  900. )
  901. assert resp.status_code == 404
  902. @pytest.mark.asyncio
  903. @pytest.mark.integration
  904. async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
  905. await device_factory(device_id="sb-upd-resp", update_status="complete", update_message="Done!")
  906. resp = await async_client.get(f"{API}/devices")
  907. assert resp.status_code == 200
  908. device = next(d for d in resp.json() if d["device_id"] == "sb-upd-resp")
  909. assert device["update_status"] == "complete"
  910. assert device["update_message"] == "Done!"
  911. @pytest.mark.asyncio
  912. @pytest.mark.integration
  913. async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
  914. """GET /devices/{id}/update-check compares device version against APP_VERSION."""
  915. await device_factory(device_id="sb-uc", firmware_version="0.1.0")
  916. resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
  917. assert resp.status_code == 200
  918. data = resp.json()
  919. assert data["current_version"] == "0.1.0"
  920. assert data["latest_version"] is not None
  921. assert data["update_available"] is True
  922. @pytest.mark.asyncio
  923. @pytest.mark.integration
  924. async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
  925. from backend.app.core.config import APP_VERSION
  926. await device_factory(device_id="sb-uc2", firmware_version=APP_VERSION)
  927. resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
  928. assert resp.status_code == 200
  929. assert resp.json()["update_available"] is False
  930. @pytest.mark.asyncio
  931. @pytest.mark.integration
  932. async def test_update_check_unknown_device_404(self, async_client: AsyncClient):
  933. resp = await async_client.get(f"{API}/devices/ghost/update-check")
  934. assert resp.status_code == 404
  935. @pytest.mark.asyncio
  936. @pytest.mark.integration
  937. async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
  938. await device_factory(device_id="sb-upd-ws")
  939. with (
  940. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  941. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  942. ):
  943. mock_ws.broadcast = AsyncMock()
  944. await async_client.post(f"{API}/devices/sb-upd-ws/update")
  945. mock_ws.broadcast.assert_called_once()
  946. msg = mock_ws.broadcast.call_args[0][0]
  947. assert msg["type"] == "spoolbuddy_update"
  948. assert msg["device_id"] == "sb-upd-ws"
  949. assert msg["update_status"] == "pending"
  950. # ============================================================================
  951. # System command endpoints
  952. # ============================================================================
  953. class TestSystemCommandEndpoints:
  954. @pytest.mark.asyncio
  955. @pytest.mark.integration
  956. async def test_queue_reboot(self, async_client: AsyncClient, device_factory):
  957. await device_factory(device_id="sb-reboot")
  958. resp = await async_client.post(
  959. f"{API}/devices/sb-reboot/system/command",
  960. json={"command": "reboot"},
  961. )
  962. assert resp.status_code == 200
  963. data = resp.json()
  964. assert data["status"] == "queued"
  965. assert data["command"] == "reboot"
  966. @pytest.mark.asyncio
  967. @pytest.mark.integration
  968. async def test_queue_shutdown(self, async_client: AsyncClient, device_factory):
  969. await device_factory(device_id="sb-shutdown")
  970. resp = await async_client.post(
  971. f"{API}/devices/sb-shutdown/system/command",
  972. json={"command": "shutdown"},
  973. )
  974. assert resp.status_code == 200
  975. assert resp.json()["command"] == "shutdown"
  976. @pytest.mark.asyncio
  977. @pytest.mark.integration
  978. async def test_queue_restart_daemon(self, async_client: AsyncClient, device_factory):
  979. await device_factory(device_id="sb-rd")
  980. resp = await async_client.post(
  981. f"{API}/devices/sb-rd/system/command",
  982. json={"command": "restart_daemon"},
  983. )
  984. assert resp.status_code == 200
  985. assert resp.json()["command"] == "restart_daemon"
  986. @pytest.mark.asyncio
  987. @pytest.mark.integration
  988. async def test_queue_restart_browser(self, async_client: AsyncClient, device_factory):
  989. await device_factory(device_id="sb-rb")
  990. resp = await async_client.post(
  991. f"{API}/devices/sb-rb/system/command",
  992. json={"command": "restart_browser"},
  993. )
  994. assert resp.status_code == 200
  995. assert resp.json()["command"] == "restart_browser"
  996. @pytest.mark.asyncio
  997. @pytest.mark.integration
  998. async def test_invalid_command_rejected(self, async_client: AsyncClient, device_factory):
  999. await device_factory(device_id="sb-invalid")
  1000. resp = await async_client.post(
  1001. f"{API}/devices/sb-invalid/system/command",
  1002. json={"command": "format_disk"},
  1003. )
  1004. assert resp.status_code == 400
  1005. assert "Invalid command" in resp.json()["detail"]
  1006. @pytest.mark.asyncio
  1007. @pytest.mark.integration
  1008. async def test_command_unknown_device_404(self, async_client: AsyncClient):
  1009. resp = await async_client.post(
  1010. f"{API}/devices/ghost/system/command",
  1011. json={"command": "reboot"},
  1012. )
  1013. assert resp.status_code == 404
  1014. @pytest.mark.asyncio
  1015. @pytest.mark.integration
  1016. async def test_command_offline_device_409(self, async_client: AsyncClient, device_factory):
  1017. await device_factory(
  1018. device_id="sb-offline-cmd",
  1019. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  1020. )
  1021. resp = await async_client.post(
  1022. f"{API}/devices/sb-offline-cmd/system/command",
  1023. json={"command": "reboot"},
  1024. )
  1025. assert resp.status_code == 409
  1026. assert "offline" in resp.json()["detail"].lower()
  1027. @pytest.mark.asyncio
  1028. @pytest.mark.integration
  1029. async def test_command_sets_pending_command(self, async_client: AsyncClient, device_factory, db_session):
  1030. device = await device_factory(device_id="sb-pending")
  1031. await async_client.post(
  1032. f"{API}/devices/sb-pending/system/command",
  1033. json={"command": "restart_daemon"},
  1034. )
  1035. await db_session.refresh(device)
  1036. assert device.pending_command == "restart_daemon"
  1037. @pytest.mark.asyncio
  1038. @pytest.mark.integration
  1039. async def test_heartbeat_clears_system_command(self, async_client: AsyncClient, device_factory):
  1040. """System commands (reboot/shutdown/restart_*) are fire-and-forget — heartbeat clears them."""
  1041. await device_factory(device_id="sb-hb-clear")
  1042. # Queue a command
  1043. await async_client.post(
  1044. f"{API}/devices/sb-hb-clear/system/command",
  1045. json={"command": "restart_browser"},
  1046. )
  1047. # Heartbeat should return the command and clear it
  1048. resp = await async_client.post(
  1049. f"{API}/devices/sb-hb-clear/heartbeat",
  1050. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 100},
  1051. )
  1052. assert resp.status_code == 200
  1053. data = resp.json()
  1054. assert data["pending_command"] == "restart_browser"