test_spoolbuddy.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743
  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.models.spool import Spool
  8. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  9. API = "/api/v1/spoolbuddy"
  10. @pytest.fixture
  11. def device_factory(db_session: AsyncSession):
  12. """Factory to create SpoolBuddyDevice records."""
  13. _counter = [0]
  14. async def _create(**kwargs):
  15. _counter[0] += 1
  16. n = _counter[0]
  17. defaults = {
  18. "device_id": f"sb-{n:04d}",
  19. "hostname": f"spoolbuddy-{n}",
  20. "ip_address": f"10.0.0.{n}",
  21. "firmware_version": "1.0.0",
  22. "has_nfc": True,
  23. "has_scale": True,
  24. "tare_offset": 0,
  25. "calibration_factor": 1.0,
  26. "last_seen": datetime.now(timezone.utc),
  27. }
  28. defaults.update(kwargs)
  29. device = SpoolBuddyDevice(**defaults)
  30. db_session.add(device)
  31. await db_session.commit()
  32. await db_session.refresh(device)
  33. return device
  34. return _create
  35. @pytest.fixture
  36. def spool_factory(db_session: AsyncSession):
  37. """Factory to create Spool records."""
  38. _counter = [0]
  39. async def _create(**kwargs):
  40. _counter[0] += 1
  41. defaults = {
  42. "material": "PLA",
  43. "subtype": "Basic",
  44. "brand": "Polymaker",
  45. "color_name": "Red",
  46. "rgba": "FF0000FF",
  47. "label_weight": 1000,
  48. "core_weight": 250,
  49. "weight_used": 0,
  50. }
  51. defaults.update(kwargs)
  52. spool = Spool(**defaults)
  53. db_session.add(spool)
  54. await db_session.commit()
  55. await db_session.refresh(spool)
  56. return spool
  57. return _create
  58. # ============================================================================
  59. # Device endpoints
  60. # ============================================================================
  61. class TestDeviceEndpoints:
  62. @pytest.mark.asyncio
  63. @pytest.mark.integration
  64. async def test_register_new_device(self, async_client: AsyncClient):
  65. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  66. mock_ws.broadcast = AsyncMock()
  67. resp = await async_client.post(
  68. f"{API}/devices/register",
  69. json={
  70. "device_id": "sb-new",
  71. "hostname": "spoolbuddy-new",
  72. "ip_address": "10.0.0.99",
  73. "firmware_version": "1.2.0",
  74. },
  75. )
  76. assert resp.status_code == 200
  77. data = resp.json()
  78. assert data["device_id"] == "sb-new"
  79. assert data["hostname"] == "spoolbuddy-new"
  80. assert data["online"] is True
  81. mock_ws.broadcast.assert_called_once()
  82. msg = mock_ws.broadcast.call_args[0][0]
  83. assert msg["type"] == "spoolbuddy_online"
  84. @pytest.mark.asyncio
  85. @pytest.mark.integration
  86. async def test_re_register_existing_device(self, async_client: AsyncClient, device_factory):
  87. device = await device_factory(
  88. device_id="sb-exist",
  89. tare_offset=12345,
  90. calibration_factor=0.0042,
  91. )
  92. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  93. mock_ws.broadcast = AsyncMock()
  94. resp = await async_client.post(
  95. f"{API}/devices/register",
  96. json={
  97. "device_id": "sb-exist",
  98. "hostname": "updated-host",
  99. "ip_address": "10.0.0.200",
  100. "firmware_version": "2.0.0",
  101. },
  102. )
  103. assert resp.status_code == 200
  104. data = resp.json()
  105. assert data["id"] == device.id
  106. assert data["hostname"] == "updated-host"
  107. assert data["ip_address"] == "10.0.0.200"
  108. assert data["firmware_version"] == "2.0.0"
  109. # Calibration preserved on re-register
  110. assert data["tare_offset"] == 12345
  111. assert data["calibration_factor"] == pytest.approx(0.0042)
  112. @pytest.mark.asyncio
  113. @pytest.mark.integration
  114. async def test_list_devices_empty(self, async_client: AsyncClient):
  115. resp = await async_client.get(f"{API}/devices")
  116. assert resp.status_code == 200
  117. assert resp.json() == []
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_list_devices(self, async_client: AsyncClient, device_factory):
  121. await device_factory(device_id="sb-a", hostname="alpha")
  122. await device_factory(device_id="sb-b", hostname="beta")
  123. resp = await async_client.get(f"{API}/devices")
  124. assert resp.status_code == 200
  125. devices = resp.json()
  126. assert len(devices) == 2
  127. hostnames = {d["hostname"] for d in devices}
  128. assert hostnames == {"alpha", "beta"}
  129. @pytest.mark.asyncio
  130. @pytest.mark.integration
  131. async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
  132. device = await device_factory(device_id="sb-hb")
  133. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  134. mock_ws.broadcast = AsyncMock()
  135. resp = await async_client.post(
  136. f"{API}/devices/sb-hb/heartbeat",
  137. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 600},
  138. )
  139. assert resp.status_code == 200
  140. data = resp.json()
  141. assert data["tare_offset"] == device.tare_offset
  142. assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
  143. @pytest.mark.asyncio
  144. @pytest.mark.integration
  145. async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
  146. await device_factory(device_id="sb-cmd", pending_command="tare")
  147. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  148. mock_ws.broadcast = AsyncMock()
  149. resp = await async_client.post(
  150. f"{API}/devices/sb-cmd/heartbeat",
  151. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  152. )
  153. assert resp.status_code == 200
  154. assert resp.json()["pending_command"] == "tare"
  155. # Second heartbeat should have no pending command (cleared)
  156. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  157. mock_ws.broadcast = AsyncMock()
  158. resp2 = await async_client.post(
  159. f"{API}/devices/sb-cmd/heartbeat",
  160. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  161. )
  162. assert resp2.json()["pending_command"] is None
  163. @pytest.mark.asyncio
  164. @pytest.mark.integration
  165. async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
  166. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  167. mock_ws.broadcast = AsyncMock()
  168. resp = await async_client.post(
  169. f"{API}/devices/nonexistent/heartbeat",
  170. json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
  171. )
  172. assert resp.status_code == 404
  173. @pytest.mark.asyncio
  174. @pytest.mark.integration
  175. async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
  176. # Create device with last_seen far in the past (offline)
  177. await device_factory(
  178. device_id="sb-offline",
  179. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  180. )
  181. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  182. mock_ws.broadcast = AsyncMock()
  183. resp = await async_client.post(
  184. f"{API}/devices/sb-offline/heartbeat",
  185. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  186. )
  187. assert resp.status_code == 200
  188. # Should broadcast online since device was offline
  189. mock_ws.broadcast.assert_called_once()
  190. msg = mock_ws.broadcast.call_args[0][0]
  191. assert msg["type"] == "spoolbuddy_online"
  192. assert msg["device_id"] == "sb-offline"
  193. # ============================================================================
  194. # NFC endpoints
  195. # ============================================================================
  196. class TestNfcEndpoints:
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
  200. spool = await spool_factory(tag_uid="AABB1122", material="PLA")
  201. mock_spool = MagicMock()
  202. mock_spool.id = spool.id
  203. mock_spool.material = spool.material
  204. mock_spool.subtype = spool.subtype
  205. mock_spool.color_name = spool.color_name
  206. mock_spool.rgba = spool.rgba
  207. mock_spool.brand = spool.brand
  208. mock_spool.label_weight = spool.label_weight
  209. mock_spool.core_weight = spool.core_weight
  210. mock_spool.weight_used = spool.weight_used
  211. with (
  212. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  213. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  214. ):
  215. mock_ws.broadcast = AsyncMock()
  216. mock_lookup.return_value = mock_spool
  217. resp = await async_client.post(
  218. f"{API}/nfc/tag-scanned",
  219. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  220. )
  221. assert resp.status_code == 200
  222. data = resp.json()
  223. assert data["matched"] is True
  224. assert data["spool_id"] == spool.id
  225. msg = mock_ws.broadcast.call_args[0][0]
  226. assert msg["type"] == "spoolbuddy_tag_matched"
  227. assert msg["spool"]["id"] == spool.id
  228. @pytest.mark.asyncio
  229. @pytest.mark.integration
  230. async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
  231. with (
  232. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  233. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  234. ):
  235. mock_ws.broadcast = AsyncMock()
  236. mock_lookup.return_value = None
  237. resp = await async_client.post(
  238. f"{API}/nfc/tag-scanned",
  239. json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
  240. )
  241. assert resp.status_code == 200
  242. data = resp.json()
  243. assert data["matched"] is False
  244. assert data["spool_id"] is None
  245. msg = mock_ws.broadcast.call_args[0][0]
  246. assert msg["type"] == "spoolbuddy_unknown_tag"
  247. @pytest.mark.asyncio
  248. @pytest.mark.integration
  249. async def test_tag_removed(self, async_client: AsyncClient):
  250. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  251. mock_ws.broadcast = AsyncMock()
  252. resp = await async_client.post(
  253. f"{API}/nfc/tag-removed",
  254. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  255. )
  256. assert resp.status_code == 200
  257. msg = mock_ws.broadcast.call_args[0][0]
  258. assert msg["type"] == "spoolbuddy_tag_removed"
  259. assert msg["device_id"] == "sb-1"
  260. assert msg["tag_uid"] == "AABB1122"
  261. # ============================================================================
  262. # NFC write-tag endpoints
  263. # ============================================================================
  264. class TestWriteTagEndpoints:
  265. @pytest.mark.asyncio
  266. @pytest.mark.integration
  267. async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
  268. device = await device_factory(device_id="sb-wt")
  269. spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
  270. resp = await async_client.post(
  271. f"{API}/nfc/write-tag",
  272. json={"device_id": device.device_id, "spool_id": spool.id},
  273. )
  274. assert resp.status_code == 200
  275. assert resp.json()["status"] == "queued"
  276. # Verify heartbeat returns write_tag command with payload
  277. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  278. mock_ws.broadcast = AsyncMock()
  279. hb = await async_client.post(
  280. f"{API}/devices/{device.device_id}/heartbeat",
  281. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  282. )
  283. hb_data = hb.json()
  284. assert hb_data["pending_command"] == "write_tag"
  285. assert hb_data["pending_write_payload"] is not None
  286. assert hb_data["pending_write_payload"]["spool_id"] == spool.id
  287. assert "ndef_data_hex" in hb_data["pending_write_payload"]
  288. @pytest.mark.asyncio
  289. @pytest.mark.integration
  290. async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
  291. """write_tag command persists across heartbeats until write-result clears it."""
  292. device = await device_factory(device_id="sb-wt-persist")
  293. spool = await spool_factory(material="PETG")
  294. await async_client.post(
  295. f"{API}/nfc/write-tag",
  296. json={"device_id": device.device_id, "spool_id": spool.id},
  297. )
  298. # First heartbeat — command present
  299. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  300. mock_ws.broadcast = AsyncMock()
  301. hb1 = await async_client.post(
  302. f"{API}/devices/{device.device_id}/heartbeat",
  303. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  304. )
  305. assert hb1.json()["pending_command"] == "write_tag"
  306. # Second heartbeat — should still be present (not cleared like tare)
  307. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  308. mock_ws.broadcast = AsyncMock()
  309. hb2 = await async_client.post(
  310. f"{API}/devices/{device.device_id}/heartbeat",
  311. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  312. )
  313. assert hb2.json()["pending_command"] == "write_tag"
  314. @pytest.mark.asyncio
  315. @pytest.mark.integration
  316. async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
  317. device = await device_factory(device_id="sb-wt-nospool")
  318. resp = await async_client.post(
  319. f"{API}/nfc/write-tag",
  320. json={"device_id": device.device_id, "spool_id": 99999},
  321. )
  322. assert resp.status_code == 404
  323. @pytest.mark.asyncio
  324. @pytest.mark.integration
  325. async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
  326. spool = await spool_factory()
  327. resp = await async_client.post(
  328. f"{API}/nfc/write-tag",
  329. json={"device_id": "nonexistent", "spool_id": spool.id},
  330. )
  331. assert resp.status_code == 404
  332. @pytest.mark.asyncio
  333. @pytest.mark.integration
  334. async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
  335. device = await device_factory(device_id="sb-wr", pending_command="write_tag")
  336. spool = await spool_factory(material="PLA", tag_uid=None)
  337. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  338. mock_ws.broadcast = AsyncMock()
  339. resp = await async_client.post(
  340. f"{API}/nfc/write-result",
  341. json={
  342. "device_id": device.device_id,
  343. "spool_id": spool.id,
  344. "tag_uid": "04AABB11223344",
  345. "success": True,
  346. },
  347. )
  348. assert resp.status_code == 200
  349. msg = mock_ws.broadcast.call_args[0][0]
  350. assert msg["type"] == "spoolbuddy_tag_written"
  351. assert msg["spool_id"] == spool.id
  352. assert msg["tag_uid"] == "04AABB11223344"
  353. # Verify spool got tag linked
  354. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  355. spool_data = spool_resp.json()
  356. assert spool_data["tag_uid"] == "04AABB11223344"
  357. assert spool_data["tag_type"] == "ntag"
  358. assert spool_data["data_origin"] == "opentag3d"
  359. assert spool_data["encode_time"] is not None
  360. @pytest.mark.asyncio
  361. @pytest.mark.integration
  362. async def test_write_result_failure_broadcasts_error(
  363. self, async_client: AsyncClient, device_factory, spool_factory
  364. ):
  365. device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
  366. spool = await spool_factory(material="PLA", tag_uid=None)
  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/write-result",
  371. json={
  372. "device_id": device.device_id,
  373. "spool_id": spool.id,
  374. "tag_uid": "04AABB",
  375. "success": False,
  376. "message": "Write or verification failed",
  377. },
  378. )
  379. assert resp.status_code == 200
  380. msg = mock_ws.broadcast.call_args[0][0]
  381. assert msg["type"] == "spoolbuddy_tag_write_failed"
  382. assert msg["message"] == "Write or verification failed"
  383. # Verify spool NOT linked
  384. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  385. assert spool_resp.json()["tag_uid"] is None
  386. @pytest.mark.asyncio
  387. @pytest.mark.integration
  388. async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
  389. device = await device_factory(
  390. device_id="sb-wr-clear",
  391. pending_command="write_tag",
  392. pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
  393. )
  394. spool = await spool_factory()
  395. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  396. mock_ws.broadcast = AsyncMock()
  397. await async_client.post(
  398. f"{API}/nfc/write-result",
  399. json={
  400. "device_id": device.device_id,
  401. "spool_id": spool.id,
  402. "tag_uid": "AABB",
  403. "success": True,
  404. },
  405. )
  406. # Heartbeat should have no pending command
  407. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  408. mock_ws.broadcast = AsyncMock()
  409. hb = await async_client.post(
  410. f"{API}/devices/{device.device_id}/heartbeat",
  411. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
  412. )
  413. assert hb.json()["pending_command"] is None
  414. assert hb.json()["pending_write_payload"] is None
  415. @pytest.mark.asyncio
  416. @pytest.mark.integration
  417. async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
  418. device = await device_factory(device_id="sb-cancel")
  419. spool = await spool_factory()
  420. # Queue a write
  421. await async_client.post(
  422. f"{API}/nfc/write-tag",
  423. json={"device_id": device.device_id, "spool_id": spool.id},
  424. )
  425. # Cancel it
  426. resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
  427. assert resp.status_code == 200
  428. # Heartbeat should have no pending command
  429. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  430. mock_ws.broadcast = AsyncMock()
  431. hb = await async_client.post(
  432. f"{API}/devices/{device.device_id}/heartbeat",
  433. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  434. )
  435. assert hb.json()["pending_command"] is None
  436. @pytest.mark.asyncio
  437. @pytest.mark.integration
  438. async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
  439. resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
  440. assert resp.status_code == 404
  441. @pytest.mark.asyncio
  442. @pytest.mark.integration
  443. async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
  444. """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
  445. device = await device_factory(device_id="sb-wt-ndef")
  446. spool = await spool_factory(
  447. material="PLA",
  448. brand="Polymaker",
  449. color_name="White",
  450. rgba="FFFFFFFF",
  451. label_weight=1000,
  452. )
  453. await async_client.post(
  454. f"{API}/nfc/write-tag",
  455. json={"device_id": device.device_id, "spool_id": spool.id},
  456. )
  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": 10},
  462. )
  463. payload = hb.json()["pending_write_payload"]
  464. ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
  465. # CC bytes
  466. assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
  467. # TLV type
  468. assert ndef_bytes[4] == 0x03
  469. # NDEF record: TNF=MIME, type=application/opentag3d
  470. assert ndef_bytes[6] == 0xD2
  471. assert ndef_bytes[9:30] == b"application/opentag3d"
  472. # Terminator
  473. assert ndef_bytes[-1] == 0xFE
  474. # Total size fits NTAG213
  475. assert len(ndef_bytes) <= 144
  476. # ============================================================================
  477. # Scale endpoints
  478. # ============================================================================
  479. class TestScaleEndpoints:
  480. @pytest.mark.asyncio
  481. @pytest.mark.integration
  482. async def test_scale_reading_broadcast(self, async_client: AsyncClient):
  483. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  484. mock_ws.broadcast = AsyncMock()
  485. resp = await async_client.post(
  486. f"{API}/scale/reading",
  487. json={
  488. "device_id": "sb-1",
  489. "weight_grams": 823.5,
  490. "stable": True,
  491. "raw_adc": 456789,
  492. },
  493. )
  494. assert resp.status_code == 200
  495. msg = mock_ws.broadcast.call_args[0][0]
  496. assert msg["type"] == "spoolbuddy_weight"
  497. assert msg["device_id"] == "sb-1"
  498. assert msg["weight_grams"] == 823.5
  499. assert msg["stable"] is True
  500. assert msg["raw_adc"] == 456789
  501. @pytest.mark.asyncio
  502. @pytest.mark.integration
  503. async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
  504. # label=1000g, core=250g, scale reads 750g
  505. # net_filament = max(0, 750 - 250) = 500
  506. # weight_used = max(0, 1000 - 500) = 500
  507. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  508. resp = await async_client.post(
  509. f"{API}/scale/update-spool-weight",
  510. json={"spool_id": spool.id, "weight_grams": 750},
  511. )
  512. assert resp.status_code == 200
  513. data = resp.json()
  514. assert data["weight_used"] == 500
  515. @pytest.mark.asyncio
  516. @pytest.mark.integration
  517. async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
  518. # label=1000g, core=250g, scale reads 1250g (full spool)
  519. # net_filament = max(0, 1250 - 250) = 1000
  520. # weight_used = max(0, 1000 - 1000) = 0
  521. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
  522. resp = await async_client.post(
  523. f"{API}/scale/update-spool-weight",
  524. json={"spool_id": spool.id, "weight_grams": 1250},
  525. )
  526. assert resp.status_code == 200
  527. data = resp.json()
  528. assert data["weight_used"] == 0
  529. @pytest.mark.asyncio
  530. @pytest.mark.integration
  531. async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
  532. """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
  533. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  534. resp = await async_client.post(
  535. f"{API}/scale/update-spool-weight",
  536. json={"spool_id": spool.id, "weight_grams": 750},
  537. )
  538. assert resp.status_code == 200
  539. # Fetch the spool via inventory API to verify stored fields
  540. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  541. assert spool_resp.status_code == 200
  542. spool_data = spool_resp.json()
  543. assert spool_data["last_scale_weight"] == 750
  544. assert spool_data["last_weighed_at"] is not None
  545. @pytest.mark.asyncio
  546. @pytest.mark.integration
  547. async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
  548. resp = await async_client.post(
  549. f"{API}/scale/update-spool-weight",
  550. json={"spool_id": 99999, "weight_grams": 500},
  551. )
  552. assert resp.status_code == 404
  553. # ============================================================================
  554. # Calibration endpoints
  555. # ============================================================================
  556. class TestCalibrationEndpoints:
  557. @pytest.mark.asyncio
  558. @pytest.mark.integration
  559. async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
  560. await device_factory(device_id="sb-tare")
  561. resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
  562. assert resp.status_code == 200
  563. assert resp.json()["status"] == "ok"
  564. # Verify pending_command via heartbeat
  565. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  566. mock_ws.broadcast = AsyncMock()
  567. hb = await async_client.post(
  568. f"{API}/devices/sb-tare/heartbeat",
  569. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
  570. )
  571. assert hb.json()["pending_command"] == "tare"
  572. @pytest.mark.asyncio
  573. @pytest.mark.integration
  574. async def test_tare_unknown_device_404(self, async_client: AsyncClient):
  575. resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
  576. assert resp.status_code == 404
  577. @pytest.mark.asyncio
  578. @pytest.mark.integration
  579. async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
  580. await device_factory(device_id="sb-st", calibration_factor=0.005)
  581. resp = await async_client.post(
  582. f"{API}/devices/sb-st/calibration/set-tare",
  583. json={"tare_offset": 54321},
  584. )
  585. assert resp.status_code == 200
  586. data = resp.json()
  587. assert data["tare_offset"] == 54321
  588. assert data["calibration_factor"] == pytest.approx(0.005)
  589. @pytest.mark.asyncio
  590. @pytest.mark.integration
  591. async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
  592. # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
  593. await device_factory(device_id="sb-cf", tare_offset=10000)
  594. resp = await async_client.post(
  595. f"{API}/devices/sb-cf/calibration/set-factor",
  596. json={"known_weight_grams": 200, "raw_adc": 50000},
  597. )
  598. assert resp.status_code == 200
  599. data = resp.json()
  600. assert data["calibration_factor"] == pytest.approx(0.005)
  601. assert data["tare_offset"] == 10000
  602. @pytest.mark.asyncio
  603. @pytest.mark.integration
  604. async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
  605. # raw_adc == tare_offset → delta is 0 → 400 error
  606. await device_factory(device_id="sb-zero", tare_offset=5000)
  607. resp = await async_client.post(
  608. f"{API}/devices/sb-zero/calibration/set-factor",
  609. json={"known_weight_grams": 100, "raw_adc": 5000},
  610. )
  611. assert resp.status_code == 400
  612. @pytest.mark.asyncio
  613. @pytest.mark.integration
  614. async def test_get_calibration(self, async_client: AsyncClient, device_factory):
  615. await device_factory(
  616. device_id="sb-gcal",
  617. tare_offset=11111,
  618. calibration_factor=0.0042,
  619. )
  620. resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
  621. assert resp.status_code == 200
  622. data = resp.json()
  623. assert data["tare_offset"] == 11111
  624. assert data["calibration_factor"] == pytest.approx(0.0042)