test_spoolbuddy.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039
  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_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
  133. device = await device_factory(device_id="sb-hb")
  134. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  135. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  136. mock_ws.broadcast = AsyncMock()
  137. resp = await async_client.post(
  138. f"{API}/devices/sb-hb/heartbeat",
  139. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 600},
  140. )
  141. assert resp.status_code == 200
  142. data = resp.json()
  143. assert data["tare_offset"] == device.tare_offset
  144. assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
  145. mock_ws.broadcast.assert_called_once()
  146. msg = mock_ws.broadcast.call_args[0][0]
  147. assert msg["type"] == "spoolbuddy_online"
  148. assert msg["device_id"] == "sb-hb"
  149. @pytest.mark.asyncio
  150. @pytest.mark.integration
  151. async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
  152. await device_factory(device_id="sb-cmd", pending_command="tare")
  153. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  154. mock_ws.broadcast = AsyncMock()
  155. resp = await async_client.post(
  156. f"{API}/devices/sb-cmd/heartbeat",
  157. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  158. )
  159. assert resp.status_code == 200
  160. assert resp.json()["pending_command"] == "tare"
  161. # Second heartbeat should have no pending command (cleared)
  162. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  163. mock_ws.broadcast = AsyncMock()
  164. resp2 = await async_client.post(
  165. f"{API}/devices/sb-cmd/heartbeat",
  166. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  167. )
  168. assert resp2.json()["pending_command"] is None
  169. @pytest.mark.asyncio
  170. @pytest.mark.integration
  171. async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
  172. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  173. mock_ws.broadcast = AsyncMock()
  174. resp = await async_client.post(
  175. f"{API}/devices/nonexistent/heartbeat",
  176. json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
  177. )
  178. assert resp.status_code == 404
  179. @pytest.mark.asyncio
  180. @pytest.mark.integration
  181. async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
  182. # Create device with last_seen far in the past (offline)
  183. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  184. await device_factory(
  185. device_id="sb-offline",
  186. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  187. )
  188. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  189. mock_ws.broadcast = AsyncMock()
  190. resp = await async_client.post(
  191. f"{API}/devices/sb-offline/heartbeat",
  192. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  193. )
  194. assert resp.status_code == 200
  195. # Should broadcast online since device was offline
  196. mock_ws.broadcast.assert_called_once()
  197. msg = mock_ws.broadcast.call_args[0][0]
  198. assert msg["type"] == "spoolbuddy_online"
  199. assert msg["device_id"] == "sb-offline"
  200. @pytest.mark.asyncio
  201. @pytest.mark.integration
  202. async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory):
  203. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  204. await device_factory(
  205. device_id="sb-already-online",
  206. last_seen=datetime.now(timezone.utc),
  207. )
  208. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  209. mock_ws.broadcast = AsyncMock()
  210. resp = await async_client.post(
  211. f"{API}/devices/sb-already-online/heartbeat",
  212. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 42},
  213. )
  214. assert resp.status_code == 200
  215. mock_ws.broadcast.assert_called_once()
  216. msg = mock_ws.broadcast.call_args[0][0]
  217. assert msg["type"] == "spoolbuddy_online"
  218. assert msg["device_id"] == "sb-already-online"
  219. @pytest.mark.asyncio
  220. @pytest.mark.integration
  221. async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory):
  222. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  223. await device_factory(
  224. device_id="sb-throttle",
  225. last_seen=datetime.now(timezone.utc),
  226. )
  227. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  228. mock_ws.broadcast = AsyncMock()
  229. resp1 = await async_client.post(
  230. f"{API}/devices/sb-throttle/heartbeat",
  231. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  232. )
  233. resp2 = await async_client.post(
  234. f"{API}/devices/sb-throttle/heartbeat",
  235. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 11},
  236. )
  237. assert resp1.status_code == 200
  238. assert resp2.status_code == 200
  239. mock_ws.broadcast.assert_called_once()
  240. msg = mock_ws.broadcast.call_args[0][0]
  241. assert msg["type"] == "spoolbuddy_online"
  242. assert msg["device_id"] == "sb-throttle"
  243. # ============================================================================
  244. # NFC endpoints
  245. # ============================================================================
  246. class TestNfcEndpoints:
  247. @pytest.mark.asyncio
  248. @pytest.mark.integration
  249. async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
  250. spool = await spool_factory(tag_uid="AABB1122", material="PLA")
  251. mock_spool = MagicMock()
  252. mock_spool.id = spool.id
  253. mock_spool.material = spool.material
  254. mock_spool.subtype = spool.subtype
  255. mock_spool.color_name = spool.color_name
  256. mock_spool.rgba = spool.rgba
  257. mock_spool.brand = spool.brand
  258. mock_spool.label_weight = spool.label_weight
  259. mock_spool.core_weight = spool.core_weight
  260. mock_spool.weight_used = spool.weight_used
  261. with (
  262. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  263. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  264. ):
  265. mock_ws.broadcast = AsyncMock()
  266. mock_lookup.return_value = mock_spool
  267. resp = await async_client.post(
  268. f"{API}/nfc/tag-scanned",
  269. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  270. )
  271. assert resp.status_code == 200
  272. data = resp.json()
  273. assert data["matched"] is True
  274. assert data["spool_id"] == spool.id
  275. msg = mock_ws.broadcast.call_args[0][0]
  276. assert msg["type"] == "spoolbuddy_tag_matched"
  277. assert msg["spool"]["id"] == spool.id
  278. @pytest.mark.asyncio
  279. @pytest.mark.integration
  280. async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
  281. with (
  282. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  283. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  284. ):
  285. mock_ws.broadcast = AsyncMock()
  286. mock_lookup.return_value = None
  287. resp = await async_client.post(
  288. f"{API}/nfc/tag-scanned",
  289. json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
  290. )
  291. assert resp.status_code == 200
  292. data = resp.json()
  293. assert data["matched"] is False
  294. assert data["spool_id"] is None
  295. msg = mock_ws.broadcast.call_args[0][0]
  296. assert msg["type"] == "spoolbuddy_unknown_tag"
  297. @pytest.mark.asyncio
  298. @pytest.mark.integration
  299. async def test_tag_removed(self, async_client: AsyncClient):
  300. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  301. mock_ws.broadcast = AsyncMock()
  302. resp = await async_client.post(
  303. f"{API}/nfc/tag-removed",
  304. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  305. )
  306. assert resp.status_code == 200
  307. msg = mock_ws.broadcast.call_args[0][0]
  308. assert msg["type"] == "spoolbuddy_tag_removed"
  309. assert msg["device_id"] == "sb-1"
  310. assert msg["tag_uid"] == "AABB1122"
  311. # ============================================================================
  312. # NFC write-tag endpoints
  313. # ============================================================================
  314. class TestWriteTagEndpoints:
  315. @pytest.mark.asyncio
  316. @pytest.mark.integration
  317. async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
  318. device = await device_factory(device_id="sb-wt")
  319. spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
  320. resp = await async_client.post(
  321. f"{API}/nfc/write-tag",
  322. json={"device_id": device.device_id, "spool_id": spool.id},
  323. )
  324. assert resp.status_code == 200
  325. assert resp.json()["status"] == "queued"
  326. # Verify heartbeat returns write_tag command with payload
  327. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  328. mock_ws.broadcast = AsyncMock()
  329. hb = await async_client.post(
  330. f"{API}/devices/{device.device_id}/heartbeat",
  331. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  332. )
  333. hb_data = hb.json()
  334. assert hb_data["pending_command"] == "write_tag"
  335. assert hb_data["pending_write_payload"] is not None
  336. assert hb_data["pending_write_payload"]["spool_id"] == spool.id
  337. assert "ndef_data_hex" in hb_data["pending_write_payload"]
  338. @pytest.mark.asyncio
  339. @pytest.mark.integration
  340. async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
  341. """write_tag command persists across heartbeats until write-result clears it."""
  342. device = await device_factory(device_id="sb-wt-persist")
  343. spool = await spool_factory(material="PETG")
  344. await async_client.post(
  345. f"{API}/nfc/write-tag",
  346. json={"device_id": device.device_id, "spool_id": spool.id},
  347. )
  348. # First heartbeat — command present
  349. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  350. mock_ws.broadcast = AsyncMock()
  351. hb1 = await async_client.post(
  352. f"{API}/devices/{device.device_id}/heartbeat",
  353. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  354. )
  355. assert hb1.json()["pending_command"] == "write_tag"
  356. # Second heartbeat — should still be present (not cleared like tare)
  357. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  358. mock_ws.broadcast = AsyncMock()
  359. hb2 = await async_client.post(
  360. f"{API}/devices/{device.device_id}/heartbeat",
  361. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  362. )
  363. assert hb2.json()["pending_command"] == "write_tag"
  364. @pytest.mark.asyncio
  365. @pytest.mark.integration
  366. async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
  367. device = await device_factory(device_id="sb-wt-nospool")
  368. resp = await async_client.post(
  369. f"{API}/nfc/write-tag",
  370. json={"device_id": device.device_id, "spool_id": 99999},
  371. )
  372. assert resp.status_code == 404
  373. @pytest.mark.asyncio
  374. @pytest.mark.integration
  375. async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
  376. spool = await spool_factory()
  377. resp = await async_client.post(
  378. f"{API}/nfc/write-tag",
  379. json={"device_id": "nonexistent", "spool_id": spool.id},
  380. )
  381. assert resp.status_code == 404
  382. @pytest.mark.asyncio
  383. @pytest.mark.integration
  384. async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
  385. device = await device_factory(device_id="sb-wr", pending_command="write_tag")
  386. spool = await spool_factory(material="PLA", tag_uid=None)
  387. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  388. mock_ws.broadcast = AsyncMock()
  389. resp = await async_client.post(
  390. f"{API}/nfc/write-result",
  391. json={
  392. "device_id": device.device_id,
  393. "spool_id": spool.id,
  394. "tag_uid": "04AABB11223344",
  395. "success": True,
  396. },
  397. )
  398. assert resp.status_code == 200
  399. msg = mock_ws.broadcast.call_args[0][0]
  400. assert msg["type"] == "spoolbuddy_tag_written"
  401. assert msg["spool_id"] == spool.id
  402. assert msg["tag_uid"] == "04AABB11223344"
  403. # Verify spool got tag linked
  404. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  405. spool_data = spool_resp.json()
  406. assert spool_data["tag_uid"] == "04AABB11223344"
  407. assert spool_data["tag_type"] == "ntag"
  408. assert spool_data["data_origin"] == "opentag3d"
  409. assert spool_data["encode_time"] is not None
  410. @pytest.mark.asyncio
  411. @pytest.mark.integration
  412. async def test_write_result_failure_broadcasts_error(
  413. self, async_client: AsyncClient, device_factory, spool_factory
  414. ):
  415. device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
  416. spool = await spool_factory(material="PLA", tag_uid=None)
  417. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  418. mock_ws.broadcast = AsyncMock()
  419. resp = await async_client.post(
  420. f"{API}/nfc/write-result",
  421. json={
  422. "device_id": device.device_id,
  423. "spool_id": spool.id,
  424. "tag_uid": "04AABB",
  425. "success": False,
  426. "message": "Write or verification failed",
  427. },
  428. )
  429. assert resp.status_code == 200
  430. msg = mock_ws.broadcast.call_args[0][0]
  431. assert msg["type"] == "spoolbuddy_tag_write_failed"
  432. assert msg["message"] == "Write or verification failed"
  433. # Verify spool NOT linked
  434. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  435. assert spool_resp.json()["tag_uid"] is None
  436. @pytest.mark.asyncio
  437. @pytest.mark.integration
  438. async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
  439. device = await device_factory(
  440. device_id="sb-wr-clear",
  441. pending_command="write_tag",
  442. pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
  443. )
  444. spool = await spool_factory()
  445. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  446. mock_ws.broadcast = AsyncMock()
  447. await async_client.post(
  448. f"{API}/nfc/write-result",
  449. json={
  450. "device_id": device.device_id,
  451. "spool_id": spool.id,
  452. "tag_uid": "AABB",
  453. "success": True,
  454. },
  455. )
  456. # Heartbeat should have no pending command
  457. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  458. mock_ws.broadcast = AsyncMock()
  459. hb = await async_client.post(
  460. f"{API}/devices/{device.device_id}/heartbeat",
  461. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
  462. )
  463. assert hb.json()["pending_command"] is None
  464. assert hb.json()["pending_write_payload"] is None
  465. @pytest.mark.asyncio
  466. @pytest.mark.integration
  467. async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
  468. device = await device_factory(device_id="sb-cancel")
  469. spool = await spool_factory()
  470. # Queue a write
  471. await async_client.post(
  472. f"{API}/nfc/write-tag",
  473. json={"device_id": device.device_id, "spool_id": spool.id},
  474. )
  475. # Cancel it
  476. resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
  477. assert resp.status_code == 200
  478. # Heartbeat should have no pending command
  479. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  480. mock_ws.broadcast = AsyncMock()
  481. hb = await async_client.post(
  482. f"{API}/devices/{device.device_id}/heartbeat",
  483. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  484. )
  485. assert hb.json()["pending_command"] is None
  486. @pytest.mark.asyncio
  487. @pytest.mark.integration
  488. async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
  489. resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
  490. assert resp.status_code == 404
  491. @pytest.mark.asyncio
  492. @pytest.mark.integration
  493. async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
  494. """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
  495. device = await device_factory(device_id="sb-wt-ndef")
  496. spool = await spool_factory(
  497. material="PLA",
  498. brand="Polymaker",
  499. color_name="White",
  500. rgba="FFFFFFFF",
  501. label_weight=1000,
  502. )
  503. await async_client.post(
  504. f"{API}/nfc/write-tag",
  505. json={"device_id": device.device_id, "spool_id": spool.id},
  506. )
  507. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  508. mock_ws.broadcast = AsyncMock()
  509. hb = await async_client.post(
  510. f"{API}/devices/{device.device_id}/heartbeat",
  511. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  512. )
  513. payload = hb.json()["pending_write_payload"]
  514. ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
  515. # CC bytes
  516. assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
  517. # TLV type
  518. assert ndef_bytes[4] == 0x03
  519. # NDEF record: TNF=MIME, type=application/opentag3d
  520. assert ndef_bytes[6] == 0xD2
  521. assert ndef_bytes[9:30] == b"application/opentag3d"
  522. # Terminator
  523. assert ndef_bytes[-1] == 0xFE
  524. # Total size fits NTAG213
  525. assert len(ndef_bytes) <= 144
  526. # ============================================================================
  527. # Scale endpoints
  528. # ============================================================================
  529. class TestScaleEndpoints:
  530. @pytest.mark.asyncio
  531. @pytest.mark.integration
  532. async def test_scale_reading_broadcast(self, async_client: AsyncClient):
  533. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  534. mock_ws.broadcast = AsyncMock()
  535. resp = await async_client.post(
  536. f"{API}/scale/reading",
  537. json={
  538. "device_id": "sb-1",
  539. "weight_grams": 823.5,
  540. "stable": True,
  541. "raw_adc": 456789,
  542. },
  543. )
  544. assert resp.status_code == 200
  545. msg = mock_ws.broadcast.call_args[0][0]
  546. assert msg["type"] == "spoolbuddy_weight"
  547. assert msg["device_id"] == "sb-1"
  548. assert msg["weight_grams"] == 823.5
  549. assert msg["stable"] is True
  550. assert msg["raw_adc"] == 456789
  551. @pytest.mark.asyncio
  552. @pytest.mark.integration
  553. async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
  554. # label=1000g, core=250g, scale reads 750g
  555. # net_filament = max(0, 750 - 250) = 500
  556. # weight_used = max(0, 1000 - 500) = 500
  557. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  558. resp = await async_client.post(
  559. f"{API}/scale/update-spool-weight",
  560. json={"spool_id": spool.id, "weight_grams": 750},
  561. )
  562. assert resp.status_code == 200
  563. data = resp.json()
  564. assert data["weight_used"] == 500
  565. @pytest.mark.asyncio
  566. @pytest.mark.integration
  567. async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
  568. # label=1000g, core=250g, scale reads 1250g (full spool)
  569. # net_filament = max(0, 1250 - 250) = 1000
  570. # weight_used = max(0, 1000 - 1000) = 0
  571. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
  572. resp = await async_client.post(
  573. f"{API}/scale/update-spool-weight",
  574. json={"spool_id": spool.id, "weight_grams": 1250},
  575. )
  576. assert resp.status_code == 200
  577. data = resp.json()
  578. assert data["weight_used"] == 0
  579. @pytest.mark.asyncio
  580. @pytest.mark.integration
  581. async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
  582. """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
  583. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  584. resp = await async_client.post(
  585. f"{API}/scale/update-spool-weight",
  586. json={"spool_id": spool.id, "weight_grams": 750},
  587. )
  588. assert resp.status_code == 200
  589. # Fetch the spool via inventory API to verify stored fields
  590. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  591. assert spool_resp.status_code == 200
  592. spool_data = spool_resp.json()
  593. assert spool_data["last_scale_weight"] == 750
  594. assert spool_data["last_weighed_at"] is not None
  595. @pytest.mark.asyncio
  596. @pytest.mark.integration
  597. async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
  598. resp = await async_client.post(
  599. f"{API}/scale/update-spool-weight",
  600. json={"spool_id": 99999, "weight_grams": 500},
  601. )
  602. assert resp.status_code == 404
  603. # ============================================================================
  604. # Calibration endpoints
  605. # ============================================================================
  606. class TestCalibrationEndpoints:
  607. @pytest.mark.asyncio
  608. @pytest.mark.integration
  609. async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
  610. await device_factory(device_id="sb-tare")
  611. resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
  612. assert resp.status_code == 200
  613. assert resp.json()["status"] == "ok"
  614. # Verify pending_command via heartbeat
  615. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  616. mock_ws.broadcast = AsyncMock()
  617. hb = await async_client.post(
  618. f"{API}/devices/sb-tare/heartbeat",
  619. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
  620. )
  621. assert hb.json()["pending_command"] == "tare"
  622. @pytest.mark.asyncio
  623. @pytest.mark.integration
  624. async def test_tare_unknown_device_404(self, async_client: AsyncClient):
  625. resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
  626. assert resp.status_code == 404
  627. @pytest.mark.asyncio
  628. @pytest.mark.integration
  629. async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
  630. await device_factory(device_id="sb-st", calibration_factor=0.005)
  631. resp = await async_client.post(
  632. f"{API}/devices/sb-st/calibration/set-tare",
  633. json={"tare_offset": 54321},
  634. )
  635. assert resp.status_code == 200
  636. data = resp.json()
  637. assert data["tare_offset"] == 54321
  638. assert data["calibration_factor"] == pytest.approx(0.005)
  639. @pytest.mark.asyncio
  640. @pytest.mark.integration
  641. async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
  642. # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
  643. await device_factory(device_id="sb-cf", tare_offset=10000)
  644. resp = await async_client.post(
  645. f"{API}/devices/sb-cf/calibration/set-factor",
  646. json={"known_weight_grams": 200, "raw_adc": 50000},
  647. )
  648. assert resp.status_code == 200
  649. data = resp.json()
  650. assert data["calibration_factor"] == pytest.approx(0.005)
  651. assert data["tare_offset"] == 10000
  652. @pytest.mark.asyncio
  653. @pytest.mark.integration
  654. async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
  655. # raw_adc == tare_offset → delta is 0 → 400 error
  656. await device_factory(device_id="sb-zero", tare_offset=5000)
  657. resp = await async_client.post(
  658. f"{API}/devices/sb-zero/calibration/set-factor",
  659. json={"known_weight_grams": 100, "raw_adc": 5000},
  660. )
  661. assert resp.status_code == 400
  662. @pytest.mark.asyncio
  663. @pytest.mark.integration
  664. async def test_get_calibration(self, async_client: AsyncClient, device_factory):
  665. await device_factory(
  666. device_id="sb-gcal",
  667. tare_offset=11111,
  668. calibration_factor=0.0042,
  669. )
  670. resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
  671. assert resp.status_code == 200
  672. data = resp.json()
  673. assert data["tare_offset"] == 11111
  674. assert data["calibration_factor"] == pytest.approx(0.0042)
  675. # ============================================================================
  676. # Display endpoints
  677. # ============================================================================
  678. class TestDisplayEndpoints:
  679. @pytest.mark.asyncio
  680. @pytest.mark.integration
  681. async def test_update_display_settings(self, async_client: AsyncClient, device_factory):
  682. await device_factory(device_id="sb-disp", display_brightness=100, display_blank_timeout=0)
  683. resp = await async_client.put(
  684. f"{API}/devices/sb-disp/display",
  685. json={"brightness": 75, "blank_timeout": 300},
  686. )
  687. assert resp.status_code == 200
  688. data = resp.json()
  689. assert data["brightness"] == 75
  690. assert data["blank_timeout"] == 300
  691. @pytest.mark.asyncio
  692. @pytest.mark.integration
  693. async def test_update_display_persists_via_heartbeat(self, async_client: AsyncClient, device_factory):
  694. await device_factory(device_id="sb-disp-hb")
  695. await async_client.put(
  696. f"{API}/devices/sb-disp-hb/display",
  697. json={"brightness": 50, "blank_timeout": 600},
  698. )
  699. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  700. mock_ws.broadcast = AsyncMock()
  701. hb = await async_client.post(
  702. f"{API}/devices/sb-disp-hb/heartbeat",
  703. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  704. )
  705. assert hb.json()["display_brightness"] == 50
  706. assert hb.json()["display_blank_timeout"] == 600
  707. @pytest.mark.asyncio
  708. @pytest.mark.integration
  709. async def test_update_display_unknown_device_404(self, async_client: AsyncClient):
  710. resp = await async_client.put(
  711. f"{API}/devices/ghost/display",
  712. json={"brightness": 50, "blank_timeout": 60},
  713. )
  714. assert resp.status_code == 404
  715. @pytest.mark.asyncio
  716. @pytest.mark.integration
  717. async def test_update_display_validates_brightness(self, async_client: AsyncClient, device_factory):
  718. await device_factory(device_id="sb-disp-val")
  719. resp = await async_client.put(
  720. f"{API}/devices/sb-disp-val/display",
  721. json={"brightness": 150, "blank_timeout": 0},
  722. )
  723. assert resp.status_code == 422 # Validation error: brightness > 100
  724. # ============================================================================
  725. # Update endpoints
  726. # ============================================================================
  727. class TestUpdateEndpoints:
  728. @pytest.mark.asyncio
  729. @pytest.mark.integration
  730. async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):
  731. await device_factory(device_id="sb-upd")
  732. with (
  733. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  734. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  735. ):
  736. mock_ws.broadcast = AsyncMock()
  737. resp = await async_client.post(f"{API}/devices/sb-upd/update")
  738. assert resp.status_code == 200
  739. assert resp.json()["status"] == "ok"
  740. @pytest.mark.asyncio
  741. @pytest.mark.integration
  742. async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
  743. await device_factory(
  744. device_id="sb-upd-off",
  745. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  746. )
  747. resp = await async_client.post(f"{API}/devices/sb-upd-off/update")
  748. assert resp.status_code == 409
  749. @pytest.mark.asyncio
  750. @pytest.mark.integration
  751. async def test_trigger_update_unknown_device_404(self, async_client: AsyncClient):
  752. resp = await async_client.post(f"{API}/devices/ghost/update")
  753. assert resp.status_code == 404
  754. @pytest.mark.asyncio
  755. @pytest.mark.integration
  756. async def test_trigger_update_already_updating(self, async_client: AsyncClient, device_factory):
  757. await device_factory(device_id="sb-upd-dup", update_status="updating")
  758. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  759. mock_ws.broadcast = AsyncMock()
  760. resp = await async_client.post(f"{API}/devices/sb-upd-dup/update")
  761. assert resp.status_code == 200
  762. assert resp.json()["status"] == "already_updating"
  763. @pytest.mark.asyncio
  764. @pytest.mark.integration
  765. async def test_report_update_status_updating(self, async_client: AsyncClient, device_factory):
  766. await device_factory(device_id="sb-upd-st", pending_command="update", update_status="pending")
  767. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  768. mock_ws.broadcast = AsyncMock()
  769. resp = await async_client.post(
  770. f"{API}/devices/sb-upd-st/update-status",
  771. json={"status": "updating", "message": "Fetching latest code..."},
  772. )
  773. assert resp.status_code == 200
  774. mock_ws.broadcast.assert_called_once()
  775. msg = mock_ws.broadcast.call_args[0][0]
  776. assert msg["type"] == "spoolbuddy_update"
  777. assert msg["update_status"] == "updating"
  778. @pytest.mark.asyncio
  779. @pytest.mark.integration
  780. async def test_report_update_status_complete_clears_command(self, async_client: AsyncClient, device_factory):
  781. await device_factory(device_id="sb-upd-done", pending_command="update", update_status="updating")
  782. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  783. mock_ws.broadcast = AsyncMock()
  784. await async_client.post(
  785. f"{API}/devices/sb-upd-done/update-status",
  786. json={"status": "complete", "message": "Update complete, restarting..."},
  787. )
  788. # Heartbeat should have no pending command
  789. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  790. mock_ws.broadcast = AsyncMock()
  791. hb = await async_client.post(
  792. f"{API}/devices/sb-upd-done/heartbeat",
  793. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  794. )
  795. assert hb.json()["pending_command"] is None
  796. @pytest.mark.asyncio
  797. @pytest.mark.integration
  798. async def test_report_update_status_error(self, async_client: AsyncClient, device_factory):
  799. await device_factory(device_id="sb-upd-err", pending_command="update", update_status="updating")
  800. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  801. mock_ws.broadcast = AsyncMock()
  802. resp = await async_client.post(
  803. f"{API}/devices/sb-upd-err/update-status",
  804. json={"status": "error", "message": "git fetch failed: network unreachable"},
  805. )
  806. assert resp.status_code == 200
  807. msg = mock_ws.broadcast.call_args[0][0]
  808. assert msg["update_status"] == "error"
  809. assert "git fetch failed" in msg["update_message"]
  810. @pytest.mark.asyncio
  811. @pytest.mark.integration
  812. async def test_report_update_status_unknown_device_404(self, async_client: AsyncClient):
  813. resp = await async_client.post(
  814. f"{API}/devices/ghost/update-status",
  815. json={"status": "updating", "message": "test"},
  816. )
  817. assert resp.status_code == 404
  818. @pytest.mark.asyncio
  819. @pytest.mark.integration
  820. async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
  821. await device_factory(device_id="sb-upd-resp", update_status="complete", update_message="Done!")
  822. resp = await async_client.get(f"{API}/devices")
  823. assert resp.status_code == 200
  824. device = next(d for d in resp.json() if d["device_id"] == "sb-upd-resp")
  825. assert device["update_status"] == "complete"
  826. assert device["update_message"] == "Done!"
  827. @pytest.mark.asyncio
  828. @pytest.mark.integration
  829. async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
  830. """GET /devices/{id}/update-check compares device version against APP_VERSION."""
  831. await device_factory(device_id="sb-uc", firmware_version="0.1.0")
  832. resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
  833. assert resp.status_code == 200
  834. data = resp.json()
  835. assert data["current_version"] == "0.1.0"
  836. assert data["latest_version"] is not None
  837. assert data["update_available"] is True
  838. @pytest.mark.asyncio
  839. @pytest.mark.integration
  840. async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
  841. from backend.app.core.config import APP_VERSION
  842. await device_factory(device_id="sb-uc2", firmware_version=APP_VERSION)
  843. resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
  844. assert resp.status_code == 200
  845. assert resp.json()["update_available"] is False
  846. @pytest.mark.asyncio
  847. @pytest.mark.integration
  848. async def test_update_check_unknown_device_404(self, async_client: AsyncClient):
  849. resp = await async_client.get(f"{API}/devices/ghost/update-check")
  850. assert resp.status_code == 404
  851. @pytest.mark.asyncio
  852. @pytest.mark.integration
  853. async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
  854. await device_factory(device_id="sb-upd-ws")
  855. with (
  856. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  857. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  858. ):
  859. mock_ws.broadcast = AsyncMock()
  860. await async_client.post(f"{API}/devices/sb-upd-ws/update")
  861. mock_ws.broadcast.assert_called_once()
  862. msg = mock_ws.broadcast.call_args[0][0]
  863. assert msg["type"] == "spoolbuddy_update"
  864. assert msg["device_id"] == "sb-upd-ws"
  865. assert msg["update_status"] == "pending"