test_spoolbuddy.py 90 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314
  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. import backend.app.services.spoolbuddy_ssh # noqa: F401 — ensures patch() can resolve the dotted path
  8. from backend.app.api.routes import spoolbuddy as spoolbuddy_routes
  9. from backend.app.models.spool import Spool
  10. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  11. from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
  12. API = "/api/v1/spoolbuddy"
  13. @pytest.fixture
  14. def device_factory(db_session: AsyncSession):
  15. """Factory to create SpoolBuddyDevice records."""
  16. _counter = [0]
  17. async def _create(**kwargs):
  18. _counter[0] += 1
  19. n = _counter[0]
  20. defaults = {
  21. "device_id": f"sb-{n:04d}",
  22. "hostname": f"spoolbuddy-{n}",
  23. "ip_address": f"10.0.0.{n}",
  24. "firmware_version": "1.0.0",
  25. "has_nfc": True,
  26. "has_scale": True,
  27. "tare_offset": 0,
  28. "calibration_factor": 1.0,
  29. "last_seen": datetime.now(timezone.utc),
  30. }
  31. defaults.update(kwargs)
  32. device = SpoolBuddyDevice(**defaults)
  33. db_session.add(device)
  34. await db_session.commit()
  35. await db_session.refresh(device)
  36. return device
  37. return _create
  38. @pytest.fixture
  39. def spool_factory(db_session: AsyncSession):
  40. """Factory to create Spool records."""
  41. _counter = [0]
  42. async def _create(**kwargs):
  43. _counter[0] += 1
  44. defaults = {
  45. "material": "PLA",
  46. "subtype": "Basic",
  47. "brand": "Polymaker",
  48. "color_name": "Red",
  49. "rgba": "FF0000FF",
  50. "label_weight": 1000,
  51. "core_weight": 250,
  52. "weight_used": 0,
  53. }
  54. defaults.update(kwargs)
  55. spool = Spool(**defaults)
  56. db_session.add(spool)
  57. await db_session.commit()
  58. await db_session.refresh(spool)
  59. return spool
  60. return _create
  61. # ============================================================================
  62. # Device endpoints
  63. # ============================================================================
  64. class TestDeviceEndpoints:
  65. @pytest.mark.asyncio
  66. @pytest.mark.integration
  67. async def test_register_new_device(self, async_client: AsyncClient):
  68. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  69. mock_ws.broadcast = AsyncMock()
  70. resp = await async_client.post(
  71. f"{API}/devices/register",
  72. json={
  73. "device_id": "sb-new",
  74. "hostname": "spoolbuddy-new",
  75. "ip_address": "10.0.0.99",
  76. "firmware_version": "1.2.0",
  77. },
  78. )
  79. assert resp.status_code == 200
  80. data = resp.json()
  81. assert data["device_id"] == "sb-new"
  82. assert data["hostname"] == "spoolbuddy-new"
  83. assert data["online"] is True
  84. mock_ws.broadcast.assert_called_once()
  85. msg = mock_ws.broadcast.call_args[0][0]
  86. assert msg["type"] == "spoolbuddy_online"
  87. @pytest.mark.asyncio
  88. @pytest.mark.integration
  89. async def test_re_register_existing_device(self, async_client: AsyncClient, device_factory):
  90. device = await device_factory(
  91. device_id="sb-exist",
  92. tare_offset=12345,
  93. calibration_factor=0.0042,
  94. )
  95. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  96. mock_ws.broadcast = AsyncMock()
  97. resp = await async_client.post(
  98. f"{API}/devices/register",
  99. json={
  100. "device_id": "sb-exist",
  101. "hostname": "updated-host",
  102. "ip_address": "10.0.0.200",
  103. "firmware_version": "2.0.0",
  104. },
  105. )
  106. assert resp.status_code == 200
  107. data = resp.json()
  108. assert data["id"] == device.id
  109. assert data["hostname"] == "updated-host"
  110. assert data["ip_address"] == "10.0.0.200"
  111. assert data["firmware_version"] == "2.0.0"
  112. # Calibration preserved on re-register
  113. assert data["tare_offset"] == 12345
  114. assert data["calibration_factor"] == pytest.approx(0.0042)
  115. @pytest.mark.asyncio
  116. @pytest.mark.integration
  117. async def test_list_devices_empty(self, async_client: AsyncClient):
  118. resp = await async_client.get(f"{API}/devices")
  119. assert resp.status_code == 200
  120. assert resp.json() == []
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_list_devices(self, async_client: AsyncClient, device_factory):
  124. await device_factory(device_id="sb-a", hostname="alpha")
  125. await device_factory(device_id="sb-b", hostname="beta")
  126. resp = await async_client.get(f"{API}/devices")
  127. assert resp.status_code == 200
  128. devices = resp.json()
  129. assert len(devices) == 2
  130. hostnames = {d["hostname"] for d in devices}
  131. assert hostnames == {"alpha", "beta"}
  132. @pytest.mark.asyncio
  133. @pytest.mark.integration
  134. async def test_unregister_device(self, async_client: AsyncClient, device_factory, db_session):
  135. await device_factory(device_id="sb-keep", hostname="keep")
  136. await device_factory(device_id="sb-drop", hostname="drop")
  137. spoolbuddy_routes._spoolbuddy_online_last_broadcast["sb-drop"] = 123.0
  138. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  139. mock_ws.broadcast = AsyncMock()
  140. resp = await async_client.delete(f"{API}/devices/sb-drop")
  141. assert resp.status_code == 200
  142. assert resp.json() == {"status": "deleted", "device_id": "sb-drop"}
  143. assert "sb-drop" not in spoolbuddy_routes._spoolbuddy_online_last_broadcast
  144. mock_ws.broadcast.assert_called_once()
  145. msg = mock_ws.broadcast.call_args[0][0]
  146. assert msg["type"] == "spoolbuddy_unregistered"
  147. assert msg["device_id"] == "sb-drop"
  148. # Other device still present
  149. resp = await async_client.get(f"{API}/devices")
  150. remaining = {d["device_id"] for d in resp.json()}
  151. assert remaining == {"sb-keep"}
  152. @pytest.mark.asyncio
  153. @pytest.mark.integration
  154. async def test_unregister_device_not_found(self, async_client: AsyncClient):
  155. resp = await async_client.delete(f"{API}/devices/sb-ghost")
  156. assert resp.status_code == 404
  157. @pytest.mark.asyncio
  158. @pytest.mark.integration
  159. async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
  160. device = await device_factory(device_id="sb-hb")
  161. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  162. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  163. mock_ws.broadcast = AsyncMock()
  164. resp = await async_client.post(
  165. f"{API}/devices/sb-hb/heartbeat",
  166. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 600},
  167. )
  168. assert resp.status_code == 200
  169. data = resp.json()
  170. assert data["tare_offset"] == device.tare_offset
  171. assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
  172. mock_ws.broadcast.assert_called_once()
  173. msg = mock_ws.broadcast.call_args[0][0]
  174. assert msg["type"] == "spoolbuddy_online"
  175. assert msg["device_id"] == "sb-hb"
  176. @pytest.mark.asyncio
  177. @pytest.mark.integration
  178. async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
  179. await device_factory(device_id="sb-cmd", pending_command="tare")
  180. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  181. mock_ws.broadcast = AsyncMock()
  182. resp = await async_client.post(
  183. f"{API}/devices/sb-cmd/heartbeat",
  184. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  185. )
  186. assert resp.status_code == 200
  187. assert resp.json()["pending_command"] == "tare"
  188. # Second heartbeat should have no pending command (cleared)
  189. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  190. mock_ws.broadcast = AsyncMock()
  191. resp2 = await async_client.post(
  192. f"{API}/devices/sb-cmd/heartbeat",
  193. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  194. )
  195. assert resp2.json()["pending_command"] is None
  196. @pytest.mark.asyncio
  197. @pytest.mark.integration
  198. async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
  199. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  200. mock_ws.broadcast = AsyncMock()
  201. resp = await async_client.post(
  202. f"{API}/devices/nonexistent/heartbeat",
  203. json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
  204. )
  205. assert resp.status_code == 404
  206. @pytest.mark.asyncio
  207. @pytest.mark.integration
  208. async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
  209. # Create device with last_seen far in the past (offline)
  210. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  211. await device_factory(
  212. device_id="sb-offline",
  213. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  214. )
  215. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  216. mock_ws.broadcast = AsyncMock()
  217. resp = await async_client.post(
  218. f"{API}/devices/sb-offline/heartbeat",
  219. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  220. )
  221. assert resp.status_code == 200
  222. # Should broadcast online since device was offline
  223. mock_ws.broadcast.assert_called_once()
  224. msg = mock_ws.broadcast.call_args[0][0]
  225. assert msg["type"] == "spoolbuddy_online"
  226. assert msg["device_id"] == "sb-offline"
  227. @pytest.mark.asyncio
  228. @pytest.mark.integration
  229. async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory):
  230. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  231. await device_factory(
  232. device_id="sb-already-online",
  233. last_seen=datetime.now(timezone.utc),
  234. )
  235. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  236. mock_ws.broadcast = AsyncMock()
  237. resp = await async_client.post(
  238. f"{API}/devices/sb-already-online/heartbeat",
  239. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 42},
  240. )
  241. assert resp.status_code == 200
  242. mock_ws.broadcast.assert_called_once()
  243. msg = mock_ws.broadcast.call_args[0][0]
  244. assert msg["type"] == "spoolbuddy_online"
  245. assert msg["device_id"] == "sb-already-online"
  246. @pytest.mark.asyncio
  247. @pytest.mark.integration
  248. async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory):
  249. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  250. await device_factory(
  251. device_id="sb-throttle",
  252. last_seen=datetime.now(timezone.utc),
  253. )
  254. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  255. mock_ws.broadcast = AsyncMock()
  256. resp1 = await async_client.post(
  257. f"{API}/devices/sb-throttle/heartbeat",
  258. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  259. )
  260. resp2 = await async_client.post(
  261. f"{API}/devices/sb-throttle/heartbeat",
  262. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 11},
  263. )
  264. assert resp1.status_code == 200
  265. assert resp2.status_code == 200
  266. mock_ws.broadcast.assert_called_once()
  267. msg = mock_ws.broadcast.call_args[0][0]
  268. assert msg["type"] == "spoolbuddy_online"
  269. assert msg["device_id"] == "sb-throttle"
  270. # ============================================================================
  271. # NFC endpoints
  272. # ============================================================================
  273. class TestNfcEndpoints:
  274. @pytest.mark.asyncio
  275. @pytest.mark.integration
  276. async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
  277. spool = await spool_factory(tag_uid="AABB1122", material="PLA")
  278. mock_spool = MagicMock()
  279. mock_spool.id = spool.id
  280. mock_spool.material = spool.material
  281. mock_spool.subtype = spool.subtype
  282. mock_spool.color_name = spool.color_name
  283. mock_spool.rgba = spool.rgba
  284. mock_spool.brand = spool.brand
  285. mock_spool.label_weight = spool.label_weight
  286. mock_spool.core_weight = spool.core_weight
  287. mock_spool.weight_used = spool.weight_used
  288. with (
  289. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  290. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  291. ):
  292. mock_ws.broadcast = AsyncMock()
  293. mock_lookup.return_value = mock_spool
  294. resp = await async_client.post(
  295. f"{API}/nfc/tag-scanned",
  296. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  297. )
  298. assert resp.status_code == 200
  299. data = resp.json()
  300. assert data["matched"] is True
  301. assert data["spool_id"] == spool.id
  302. msg = mock_ws.broadcast.call_args[0][0]
  303. assert msg["type"] == "spoolbuddy_tag_matched"
  304. assert msg["spool"]["id"] == spool.id
  305. @pytest.mark.asyncio
  306. @pytest.mark.integration
  307. async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
  308. with (
  309. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  310. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  311. ):
  312. mock_ws.broadcast = AsyncMock()
  313. mock_lookup.return_value = None
  314. resp = await async_client.post(
  315. f"{API}/nfc/tag-scanned",
  316. json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
  317. )
  318. assert resp.status_code == 200
  319. data = resp.json()
  320. assert data["matched"] is False
  321. assert data["spool_id"] is None
  322. msg = mock_ws.broadcast.call_args[0][0]
  323. assert msg["type"] == "spoolbuddy_unknown_tag"
  324. @pytest.mark.asyncio
  325. @pytest.mark.integration
  326. async def test_tag_removed(self, async_client: AsyncClient):
  327. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  328. mock_ws.broadcast = AsyncMock()
  329. resp = await async_client.post(
  330. f"{API}/nfc/tag-removed",
  331. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  332. )
  333. assert resp.status_code == 200
  334. msg = mock_ws.broadcast.call_args[0][0]
  335. assert msg["type"] == "spoolbuddy_tag_removed"
  336. assert msg["device_id"] == "sb-1"
  337. assert msg["tag_uid"] == "AABB1122"
  338. # ============================================================================
  339. # NFC write-tag endpoints
  340. # ============================================================================
  341. class TestWriteTagEndpoints:
  342. @pytest.mark.asyncio
  343. @pytest.mark.integration
  344. async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
  345. device = await device_factory(device_id="sb-wt")
  346. spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
  347. resp = await async_client.post(
  348. f"{API}/nfc/write-tag",
  349. json={"device_id": device.device_id, "spool_id": spool.id},
  350. )
  351. assert resp.status_code == 200
  352. assert resp.json()["status"] == "queued"
  353. # Verify heartbeat returns write_tag command with payload
  354. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  355. mock_ws.broadcast = AsyncMock()
  356. hb = await async_client.post(
  357. f"{API}/devices/{device.device_id}/heartbeat",
  358. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  359. )
  360. hb_data = hb.json()
  361. assert hb_data["pending_command"] == "write_tag"
  362. assert hb_data["pending_write_payload"] is not None
  363. assert hb_data["pending_write_payload"]["spool_id"] == spool.id
  364. assert "ndef_data_hex" in hb_data["pending_write_payload"]
  365. @pytest.mark.asyncio
  366. @pytest.mark.integration
  367. async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
  368. """write_tag command persists across heartbeats until write-result clears it."""
  369. device = await device_factory(device_id="sb-wt-persist")
  370. spool = await spool_factory(material="PETG")
  371. await async_client.post(
  372. f"{API}/nfc/write-tag",
  373. json={"device_id": device.device_id, "spool_id": spool.id},
  374. )
  375. # First heartbeat — command present
  376. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  377. mock_ws.broadcast = AsyncMock()
  378. hb1 = await async_client.post(
  379. f"{API}/devices/{device.device_id}/heartbeat",
  380. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  381. )
  382. assert hb1.json()["pending_command"] == "write_tag"
  383. # Second heartbeat — should still be present (not cleared like tare)
  384. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  385. mock_ws.broadcast = AsyncMock()
  386. hb2 = await async_client.post(
  387. f"{API}/devices/{device.device_id}/heartbeat",
  388. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  389. )
  390. assert hb2.json()["pending_command"] == "write_tag"
  391. @pytest.mark.asyncio
  392. @pytest.mark.integration
  393. async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
  394. device = await device_factory(device_id="sb-wt-nospool")
  395. resp = await async_client.post(
  396. f"{API}/nfc/write-tag",
  397. json={"device_id": device.device_id, "spool_id": 99999},
  398. )
  399. assert resp.status_code == 404
  400. @pytest.mark.asyncio
  401. @pytest.mark.integration
  402. async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
  403. spool = await spool_factory()
  404. resp = await async_client.post(
  405. f"{API}/nfc/write-tag",
  406. json={"device_id": "nonexistent", "spool_id": spool.id},
  407. )
  408. assert resp.status_code == 404
  409. @pytest.mark.asyncio
  410. @pytest.mark.integration
  411. async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
  412. device = await device_factory(device_id="sb-wr", pending_command="write_tag")
  413. spool = await spool_factory(material="PLA", tag_uid=None)
  414. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  415. mock_ws.broadcast = AsyncMock()
  416. resp = await async_client.post(
  417. f"{API}/nfc/write-result",
  418. json={
  419. "device_id": device.device_id,
  420. "spool_id": spool.id,
  421. "tag_uid": "04AABB11223344",
  422. "success": True,
  423. },
  424. )
  425. assert resp.status_code == 200
  426. msg = mock_ws.broadcast.call_args[0][0]
  427. assert msg["type"] == "spoolbuddy_tag_written"
  428. assert msg["spool_id"] == spool.id
  429. assert msg["tag_uid"] == "04AABB11223344"
  430. # Verify spool got tag linked
  431. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  432. spool_data = spool_resp.json()
  433. assert spool_data["tag_uid"] == "04AABB11223344"
  434. assert spool_data["tag_type"] == "ntag"
  435. assert spool_data["data_origin"] == "opentag3d"
  436. assert spool_data["encode_time"] is not None
  437. @pytest.mark.asyncio
  438. @pytest.mark.integration
  439. async def test_write_result_failure_broadcasts_error(
  440. self, async_client: AsyncClient, device_factory, spool_factory
  441. ):
  442. device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
  443. spool = await spool_factory(material="PLA", tag_uid=None)
  444. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  445. mock_ws.broadcast = AsyncMock()
  446. resp = await async_client.post(
  447. f"{API}/nfc/write-result",
  448. json={
  449. "device_id": device.device_id,
  450. "spool_id": spool.id,
  451. "tag_uid": "04AABBCC",
  452. "success": False,
  453. "message": "Write or verification failed",
  454. },
  455. )
  456. assert resp.status_code == 200
  457. msg = mock_ws.broadcast.call_args[0][0]
  458. assert msg["type"] == "spoolbuddy_tag_write_failed"
  459. assert msg["message"] == "Write or verification failed"
  460. # Verify spool NOT linked
  461. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  462. assert spool_resp.json()["tag_uid"] is None
  463. @pytest.mark.asyncio
  464. @pytest.mark.integration
  465. async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
  466. device = await device_factory(
  467. device_id="sb-wr-clear",
  468. pending_command="write_tag",
  469. pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
  470. )
  471. spool = await spool_factory()
  472. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  473. mock_ws.broadcast = AsyncMock()
  474. await async_client.post(
  475. f"{API}/nfc/write-result",
  476. json={
  477. "device_id": device.device_id,
  478. "spool_id": spool.id,
  479. "tag_uid": "AABBCCDD",
  480. "success": True,
  481. },
  482. )
  483. # Heartbeat should have no pending command
  484. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  485. mock_ws.broadcast = AsyncMock()
  486. hb = await async_client.post(
  487. f"{API}/devices/{device.device_id}/heartbeat",
  488. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
  489. )
  490. assert hb.json()["pending_command"] is None
  491. assert hb.json()["pending_write_payload"] is None
  492. @pytest.mark.asyncio
  493. @pytest.mark.integration
  494. async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
  495. device = await device_factory(device_id="sb-cancel")
  496. spool = await spool_factory()
  497. # Queue a write
  498. await async_client.post(
  499. f"{API}/nfc/write-tag",
  500. json={"device_id": device.device_id, "spool_id": spool.id},
  501. )
  502. # Cancel it
  503. resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
  504. assert resp.status_code == 200
  505. # Heartbeat should have no pending command
  506. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  507. mock_ws.broadcast = AsyncMock()
  508. hb = await async_client.post(
  509. f"{API}/devices/{device.device_id}/heartbeat",
  510. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  511. )
  512. assert hb.json()["pending_command"] is None
  513. @pytest.mark.asyncio
  514. @pytest.mark.integration
  515. async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
  516. resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
  517. assert resp.status_code == 404
  518. @pytest.mark.asyncio
  519. @pytest.mark.integration
  520. async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
  521. """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
  522. device = await device_factory(device_id="sb-wt-ndef")
  523. spool = await spool_factory(
  524. material="PLA",
  525. brand="Polymaker",
  526. color_name="White",
  527. rgba="FFFFFFFF",
  528. label_weight=1000,
  529. )
  530. await async_client.post(
  531. f"{API}/nfc/write-tag",
  532. json={"device_id": device.device_id, "spool_id": spool.id},
  533. )
  534. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  535. mock_ws.broadcast = AsyncMock()
  536. hb = await async_client.post(
  537. f"{API}/devices/{device.device_id}/heartbeat",
  538. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  539. )
  540. payload = hb.json()["pending_write_payload"]
  541. ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
  542. # CC bytes
  543. assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
  544. # TLV type
  545. assert ndef_bytes[4] == 0x03
  546. # NDEF record: TNF=MIME, type=application/opentag3d
  547. assert ndef_bytes[6] == 0xD2
  548. assert ndef_bytes[9:30] == b"application/opentag3d"
  549. # Terminator
  550. assert ndef_bytes[-1] == 0xFE
  551. # Total size fits NTAG213
  552. assert len(ndef_bytes) <= 144
  553. # ============================================================================
  554. # Scale endpoints
  555. # ============================================================================
  556. class TestScaleEndpoints:
  557. @pytest.mark.asyncio
  558. @pytest.mark.integration
  559. async def test_scale_reading_broadcast(self, async_client: AsyncClient):
  560. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  561. mock_ws.broadcast = AsyncMock()
  562. resp = await async_client.post(
  563. f"{API}/scale/reading",
  564. json={
  565. "device_id": "sb-1",
  566. "weight_grams": 823.5,
  567. "stable": True,
  568. "raw_adc": 456789,
  569. },
  570. )
  571. assert resp.status_code == 200
  572. msg = mock_ws.broadcast.call_args[0][0]
  573. assert msg["type"] == "spoolbuddy_weight"
  574. assert msg["device_id"] == "sb-1"
  575. assert msg["weight_grams"] == 823.5
  576. assert msg["stable"] is True
  577. assert msg["raw_adc"] == 456789
  578. @pytest.mark.asyncio
  579. @pytest.mark.integration
  580. async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
  581. # label=1000g, core=250g, scale reads 750g
  582. # net_filament = max(0, 750 - 250) = 500
  583. # weight_used = max(0, 1000 - 500) = 500
  584. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  585. resp = await async_client.post(
  586. f"{API}/scale/update-spool-weight",
  587. json={"spool_id": spool.id, "weight_grams": 750},
  588. )
  589. assert resp.status_code == 200
  590. data = resp.json()
  591. assert data["weight_used"] == 500
  592. @pytest.mark.asyncio
  593. @pytest.mark.integration
  594. async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
  595. # label=1000g, core=250g, scale reads 1250g (full spool)
  596. # net_filament = max(0, 1250 - 250) = 1000
  597. # weight_used = max(0, 1000 - 1000) = 0
  598. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
  599. resp = await async_client.post(
  600. f"{API}/scale/update-spool-weight",
  601. json={"spool_id": spool.id, "weight_grams": 1250},
  602. )
  603. assert resp.status_code == 200
  604. data = resp.json()
  605. assert data["weight_used"] == 0
  606. @pytest.mark.asyncio
  607. @pytest.mark.integration
  608. async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
  609. """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
  610. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  611. resp = await async_client.post(
  612. f"{API}/scale/update-spool-weight",
  613. json={"spool_id": spool.id, "weight_grams": 750},
  614. )
  615. assert resp.status_code == 200
  616. # Fetch the spool via inventory API to verify stored fields
  617. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  618. assert spool_resp.status_code == 200
  619. spool_data = spool_resp.json()
  620. assert spool_data["last_scale_weight"] == 750
  621. assert spool_data["last_weighed_at"] is not None
  622. @pytest.mark.asyncio
  623. @pytest.mark.integration
  624. async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
  625. resp = await async_client.post(
  626. f"{API}/scale/update-spool-weight",
  627. json={"spool_id": 99999, "weight_grams": 500},
  628. )
  629. assert resp.status_code == 404
  630. # ============================================================================
  631. # Calibration endpoints
  632. # ============================================================================
  633. class TestCalibrationEndpoints:
  634. @pytest.mark.asyncio
  635. @pytest.mark.integration
  636. async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
  637. await device_factory(device_id="sb-tare")
  638. resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
  639. assert resp.status_code == 200
  640. assert resp.json()["status"] == "ok"
  641. # Verify pending_command via heartbeat
  642. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  643. mock_ws.broadcast = AsyncMock()
  644. hb = await async_client.post(
  645. f"{API}/devices/sb-tare/heartbeat",
  646. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
  647. )
  648. assert hb.json()["pending_command"] == "tare"
  649. @pytest.mark.asyncio
  650. @pytest.mark.integration
  651. async def test_tare_unknown_device_404(self, async_client: AsyncClient):
  652. resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
  653. assert resp.status_code == 404
  654. @pytest.mark.asyncio
  655. @pytest.mark.integration
  656. async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
  657. await device_factory(device_id="sb-st", calibration_factor=0.005)
  658. resp = await async_client.post(
  659. f"{API}/devices/sb-st/calibration/set-tare",
  660. json={"tare_offset": 54321},
  661. )
  662. assert resp.status_code == 200
  663. data = resp.json()
  664. assert data["tare_offset"] == 54321
  665. assert data["calibration_factor"] == pytest.approx(0.005)
  666. @pytest.mark.asyncio
  667. @pytest.mark.integration
  668. async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
  669. # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
  670. await device_factory(device_id="sb-cf", tare_offset=10000)
  671. resp = await async_client.post(
  672. f"{API}/devices/sb-cf/calibration/set-factor",
  673. json={"known_weight_grams": 200, "raw_adc": 50000},
  674. )
  675. assert resp.status_code == 200
  676. data = resp.json()
  677. assert data["calibration_factor"] == pytest.approx(0.005)
  678. assert data["tare_offset"] == 10000
  679. @pytest.mark.asyncio
  680. @pytest.mark.integration
  681. async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
  682. # raw_adc == tare_offset → delta is 0 → 400 error
  683. await device_factory(device_id="sb-zero", tare_offset=5000)
  684. resp = await async_client.post(
  685. f"{API}/devices/sb-zero/calibration/set-factor",
  686. json={"known_weight_grams": 100, "raw_adc": 5000},
  687. )
  688. assert resp.status_code == 400
  689. @pytest.mark.asyncio
  690. @pytest.mark.integration
  691. async def test_get_calibration(self, async_client: AsyncClient, device_factory):
  692. await device_factory(
  693. device_id="sb-gcal",
  694. tare_offset=11111,
  695. calibration_factor=0.0042,
  696. )
  697. resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
  698. assert resp.status_code == 200
  699. data = resp.json()
  700. assert data["tare_offset"] == 11111
  701. assert data["calibration_factor"] == pytest.approx(0.0042)
  702. # ============================================================================
  703. # Display endpoints
  704. # ============================================================================
  705. class TestDisplayEndpoints:
  706. @pytest.mark.asyncio
  707. @pytest.mark.integration
  708. async def test_update_display_settings(self, async_client: AsyncClient, device_factory):
  709. await device_factory(device_id="sb-disp", display_brightness=100, display_blank_timeout=0)
  710. resp = await async_client.put(
  711. f"{API}/devices/sb-disp/display",
  712. json={"brightness": 75, "blank_timeout": 300},
  713. )
  714. assert resp.status_code == 200
  715. data = resp.json()
  716. assert data["brightness"] == 75
  717. assert data["blank_timeout"] == 300
  718. @pytest.mark.asyncio
  719. @pytest.mark.integration
  720. async def test_update_display_persists_via_heartbeat(self, async_client: AsyncClient, device_factory):
  721. await device_factory(device_id="sb-disp-hb")
  722. await async_client.put(
  723. f"{API}/devices/sb-disp-hb/display",
  724. json={"brightness": 50, "blank_timeout": 600},
  725. )
  726. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  727. mock_ws.broadcast = AsyncMock()
  728. hb = await async_client.post(
  729. f"{API}/devices/sb-disp-hb/heartbeat",
  730. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  731. )
  732. assert hb.json()["display_brightness"] == 50
  733. assert hb.json()["display_blank_timeout"] == 600
  734. @pytest.mark.asyncio
  735. @pytest.mark.integration
  736. async def test_update_display_unknown_device_404(self, async_client: AsyncClient):
  737. resp = await async_client.put(
  738. f"{API}/devices/ghost/display",
  739. json={"brightness": 50, "blank_timeout": 60},
  740. )
  741. assert resp.status_code == 404
  742. @pytest.mark.asyncio
  743. @pytest.mark.integration
  744. async def test_update_display_validates_brightness(self, async_client: AsyncClient, device_factory):
  745. await device_factory(device_id="sb-disp-val")
  746. resp = await async_client.put(
  747. f"{API}/devices/sb-disp-val/display",
  748. json={"brightness": 150, "blank_timeout": 0},
  749. )
  750. assert resp.status_code == 422 # Validation error: brightness > 100
  751. @pytest.mark.asyncio
  752. @pytest.mark.integration
  753. async def test_get_display_settings(self, async_client: AsyncClient, device_factory):
  754. """The kiosk idle watchdog (install/spoolbuddy-idle.sh) reads this
  755. endpoint on autostart to configure swayidle with the user-selected
  756. blank timeout before launching. See issue #937."""
  757. await device_factory(device_id="sb-disp-get", display_brightness=60, display_blank_timeout=450)
  758. resp = await async_client.get(f"{API}/devices/sb-disp-get/display")
  759. assert resp.status_code == 200
  760. data = resp.json()
  761. assert data["brightness"] == 60
  762. assert data["blank_timeout"] == 450
  763. @pytest.mark.asyncio
  764. @pytest.mark.integration
  765. async def test_get_display_unknown_device_404(self, async_client: AsyncClient):
  766. resp = await async_client.get(f"{API}/devices/ghost/display")
  767. assert resp.status_code == 404
  768. # ============================================================================
  769. # Update endpoints
  770. # ============================================================================
  771. class TestUpdateEndpoints:
  772. @pytest.mark.asyncio
  773. @pytest.mark.integration
  774. async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):
  775. await device_factory(device_id="sb-upd")
  776. with (
  777. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  778. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  779. ):
  780. mock_ws.broadcast = AsyncMock()
  781. resp = await async_client.post(f"{API}/devices/sb-upd/update")
  782. assert resp.status_code == 200
  783. assert resp.json()["status"] == "ok"
  784. @pytest.mark.asyncio
  785. @pytest.mark.integration
  786. async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
  787. await device_factory(
  788. device_id="sb-upd-off",
  789. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  790. )
  791. resp = await async_client.post(f"{API}/devices/sb-upd-off/update")
  792. assert resp.status_code == 409
  793. @pytest.mark.asyncio
  794. @pytest.mark.integration
  795. async def test_trigger_update_unknown_device_404(self, async_client: AsyncClient):
  796. resp = await async_client.post(f"{API}/devices/ghost/update")
  797. assert resp.status_code == 404
  798. @pytest.mark.asyncio
  799. @pytest.mark.integration
  800. async def test_trigger_update_already_updating(self, async_client: AsyncClient, device_factory):
  801. await device_factory(device_id="sb-upd-dup", update_status="updating")
  802. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  803. mock_ws.broadcast = AsyncMock()
  804. resp = await async_client.post(f"{API}/devices/sb-upd-dup/update")
  805. assert resp.status_code == 200
  806. assert resp.json()["status"] == "already_updating"
  807. @pytest.mark.asyncio
  808. @pytest.mark.integration
  809. async def test_report_update_status_updating(self, async_client: AsyncClient, device_factory):
  810. await device_factory(device_id="sb-upd-st", pending_command="update", update_status="pending")
  811. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  812. mock_ws.broadcast = AsyncMock()
  813. resp = await async_client.post(
  814. f"{API}/devices/sb-upd-st/update-status",
  815. json={"status": "updating", "message": "Fetching latest code..."},
  816. )
  817. assert resp.status_code == 200
  818. mock_ws.broadcast.assert_called_once()
  819. msg = mock_ws.broadcast.call_args[0][0]
  820. assert msg["type"] == "spoolbuddy_update"
  821. assert msg["update_status"] == "updating"
  822. @pytest.mark.asyncio
  823. @pytest.mark.integration
  824. async def test_report_update_status_complete_clears_command(self, async_client: AsyncClient, device_factory):
  825. await device_factory(device_id="sb-upd-done", pending_command="update", update_status="updating")
  826. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  827. mock_ws.broadcast = AsyncMock()
  828. await async_client.post(
  829. f"{API}/devices/sb-upd-done/update-status",
  830. json={"status": "complete", "message": "Update complete, restarting..."},
  831. )
  832. # Heartbeat should have no pending command
  833. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  834. mock_ws.broadcast = AsyncMock()
  835. hb = await async_client.post(
  836. f"{API}/devices/sb-upd-done/heartbeat",
  837. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  838. )
  839. assert hb.json()["pending_command"] is None
  840. @pytest.mark.asyncio
  841. @pytest.mark.integration
  842. async def test_report_update_status_error(self, async_client: AsyncClient, device_factory):
  843. await device_factory(device_id="sb-upd-err", pending_command="update", update_status="updating")
  844. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  845. mock_ws.broadcast = AsyncMock()
  846. resp = await async_client.post(
  847. f"{API}/devices/sb-upd-err/update-status",
  848. json={"status": "error", "message": "git fetch failed: network unreachable"},
  849. )
  850. assert resp.status_code == 200
  851. msg = mock_ws.broadcast.call_args[0][0]
  852. assert msg["update_status"] == "error"
  853. assert "git fetch failed" in msg["update_message"]
  854. @pytest.mark.asyncio
  855. @pytest.mark.integration
  856. async def test_report_update_status_unknown_device_404(self, async_client: AsyncClient):
  857. resp = await async_client.post(
  858. f"{API}/devices/ghost/update-status",
  859. json={"status": "updating", "message": "test"},
  860. )
  861. assert resp.status_code == 404
  862. @pytest.mark.asyncio
  863. @pytest.mark.integration
  864. async def test_report_update_status_invalid_status_422(self, async_client: AsyncClient, device_factory):
  865. """Arbitrary status strings must be rejected with 422 (H2: UpdateStatusRequest validation)."""
  866. await device_factory(device_id="sb-upd-inv")
  867. resp = await async_client.post(
  868. f"{API}/devices/sb-upd-inv/update-status",
  869. json={"status": "hacked", "message": "injected"},
  870. )
  871. assert resp.status_code == 422
  872. @pytest.mark.asyncio
  873. @pytest.mark.integration
  874. async def test_report_update_status_oversized_message_422(self, async_client: AsyncClient, device_factory):
  875. """Message exceeding 255 chars must be rejected with 422 (H2/M4)."""
  876. await device_factory(device_id="sb-upd-big")
  877. resp = await async_client.post(
  878. f"{API}/devices/sb-upd-big/update-status",
  879. json={"status": "updating", "message": "x" * 256},
  880. )
  881. assert resp.status_code == 422
  882. @pytest.mark.asyncio
  883. @pytest.mark.integration
  884. async def test_ssh_public_key_error_does_not_leak_exception_text(self, async_client: AsyncClient):
  885. """SSH public-key 500 must not expose raw exception details (M3)."""
  886. from backend.app.services.spoolbuddy_ssh import get_public_key
  887. with patch(
  888. "backend.app.services.spoolbuddy_ssh.get_public_key",
  889. AsyncMock(side_effect=RuntimeError("SECRET internal path /data/keys/id_ed25519")),
  890. ):
  891. resp = await async_client.get(f"{API}/ssh/public-key")
  892. assert resp.status_code == 500
  893. body = resp.json()["detail"]
  894. assert "SECRET" not in body
  895. assert "/data/keys" not in body
  896. assert "id_ed25519" not in body
  897. @pytest.mark.asyncio
  898. @pytest.mark.integration
  899. async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
  900. await device_factory(device_id="sb-upd-resp", update_status="complete", update_message="Done!")
  901. resp = await async_client.get(f"{API}/devices")
  902. assert resp.status_code == 200
  903. device = next(d for d in resp.json() if d["device_id"] == "sb-upd-resp")
  904. assert device["update_status"] == "complete"
  905. assert device["update_message"] == "Done!"
  906. @pytest.mark.asyncio
  907. @pytest.mark.integration
  908. async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
  909. """GET /devices/{id}/update-check compares device version against APP_VERSION."""
  910. await device_factory(device_id="sb-uc", firmware_version="0.1.0")
  911. resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
  912. assert resp.status_code == 200
  913. data = resp.json()
  914. assert data["current_version"] == "0.1.0"
  915. assert data["latest_version"] is not None
  916. assert data["update_available"] is True
  917. @pytest.mark.asyncio
  918. @pytest.mark.integration
  919. async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
  920. from backend.app.core.config import APP_VERSION
  921. await device_factory(device_id="sb-uc2", firmware_version=APP_VERSION)
  922. resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
  923. assert resp.status_code == 200
  924. assert resp.json()["update_available"] is False
  925. @pytest.mark.asyncio
  926. @pytest.mark.integration
  927. async def test_update_check_unknown_device_404(self, async_client: AsyncClient):
  928. resp = await async_client.get(f"{API}/devices/ghost/update-check")
  929. assert resp.status_code == 404
  930. @pytest.mark.asyncio
  931. @pytest.mark.integration
  932. async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
  933. await device_factory(device_id="sb-upd-ws")
  934. with (
  935. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  936. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  937. ):
  938. mock_ws.broadcast = AsyncMock()
  939. await async_client.post(f"{API}/devices/sb-upd-ws/update")
  940. mock_ws.broadcast.assert_called_once()
  941. msg = mock_ws.broadcast.call_args[0][0]
  942. assert msg["type"] == "spoolbuddy_update"
  943. assert msg["device_id"] == "sb-upd-ws"
  944. assert msg["update_status"] == "pending"
  945. # ============================================================================
  946. # System command endpoints
  947. # ============================================================================
  948. class TestSystemCommandEndpoints:
  949. @pytest.mark.asyncio
  950. @pytest.mark.integration
  951. async def test_queue_reboot(self, async_client: AsyncClient, device_factory):
  952. await device_factory(device_id="sb-reboot")
  953. resp = await async_client.post(
  954. f"{API}/devices/sb-reboot/system/command",
  955. json={"command": "reboot"},
  956. )
  957. assert resp.status_code == 200
  958. data = resp.json()
  959. assert data["status"] == "queued"
  960. assert data["command"] == "reboot"
  961. @pytest.mark.asyncio
  962. @pytest.mark.integration
  963. async def test_queue_shutdown(self, async_client: AsyncClient, device_factory):
  964. await device_factory(device_id="sb-shutdown")
  965. resp = await async_client.post(
  966. f"{API}/devices/sb-shutdown/system/command",
  967. json={"command": "shutdown"},
  968. )
  969. assert resp.status_code == 200
  970. assert resp.json()["command"] == "shutdown"
  971. @pytest.mark.asyncio
  972. @pytest.mark.integration
  973. async def test_queue_restart_daemon(self, async_client: AsyncClient, device_factory):
  974. await device_factory(device_id="sb-rd")
  975. resp = await async_client.post(
  976. f"{API}/devices/sb-rd/system/command",
  977. json={"command": "restart_daemon"},
  978. )
  979. assert resp.status_code == 200
  980. assert resp.json()["command"] == "restart_daemon"
  981. @pytest.mark.asyncio
  982. @pytest.mark.integration
  983. async def test_queue_restart_browser(self, async_client: AsyncClient, device_factory):
  984. await device_factory(device_id="sb-rb")
  985. resp = await async_client.post(
  986. f"{API}/devices/sb-rb/system/command",
  987. json={"command": "restart_browser"},
  988. )
  989. assert resp.status_code == 200
  990. assert resp.json()["command"] == "restart_browser"
  991. @pytest.mark.asyncio
  992. @pytest.mark.integration
  993. async def test_invalid_command_rejected(self, async_client: AsyncClient, device_factory):
  994. await device_factory(device_id="sb-invalid")
  995. resp = await async_client.post(
  996. f"{API}/devices/sb-invalid/system/command",
  997. json={"command": "format_disk"},
  998. )
  999. assert resp.status_code == 400
  1000. assert "Invalid command" in resp.json()["detail"]
  1001. @pytest.mark.asyncio
  1002. @pytest.mark.integration
  1003. async def test_command_unknown_device_404(self, async_client: AsyncClient):
  1004. resp = await async_client.post(
  1005. f"{API}/devices/ghost/system/command",
  1006. json={"command": "reboot"},
  1007. )
  1008. assert resp.status_code == 404
  1009. @pytest.mark.asyncio
  1010. @pytest.mark.integration
  1011. async def test_command_offline_device_409(self, async_client: AsyncClient, device_factory):
  1012. await device_factory(
  1013. device_id="sb-offline-cmd",
  1014. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  1015. )
  1016. resp = await async_client.post(
  1017. f"{API}/devices/sb-offline-cmd/system/command",
  1018. json={"command": "reboot"},
  1019. )
  1020. assert resp.status_code == 409
  1021. assert "offline" in resp.json()["detail"].lower()
  1022. @pytest.mark.asyncio
  1023. @pytest.mark.integration
  1024. async def test_command_sets_pending_command(self, async_client: AsyncClient, device_factory, db_session):
  1025. device = await device_factory(device_id="sb-pending")
  1026. await async_client.post(
  1027. f"{API}/devices/sb-pending/system/command",
  1028. json={"command": "restart_daemon"},
  1029. )
  1030. await db_session.refresh(device)
  1031. assert device.pending_command == "restart_daemon"
  1032. @pytest.mark.asyncio
  1033. @pytest.mark.integration
  1034. async def test_heartbeat_clears_system_command(self, async_client: AsyncClient, device_factory):
  1035. """System commands (reboot/shutdown/restart_*) are fire-and-forget — heartbeat clears them."""
  1036. await device_factory(device_id="sb-hb-clear")
  1037. # Queue a command
  1038. await async_client.post(
  1039. f"{API}/devices/sb-hb-clear/system/command",
  1040. json={"command": "restart_browser"},
  1041. )
  1042. # Heartbeat should return the command and clear it
  1043. resp = await async_client.post(
  1044. f"{API}/devices/sb-hb-clear/heartbeat",
  1045. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 100},
  1046. )
  1047. assert resp.status_code == 200
  1048. data = resp.json()
  1049. assert data["pending_command"] == "restart_browser"
  1050. # ============================================================================
  1051. # Spoolman-aware SpoolBuddy endpoints
  1052. # ============================================================================
  1053. @pytest.fixture
  1054. async def spoolman_settings(db_session: AsyncSession):
  1055. """Create Spoolman settings in the database (enabled with URL)."""
  1056. from backend.app.models.settings import Settings
  1057. settings = [
  1058. Settings(key="spoolman_enabled", value="true"),
  1059. Settings(key="spoolman_url", value="http://spoolman.local:7912"),
  1060. ]
  1061. for s in settings:
  1062. db_session.add(s)
  1063. await db_session.commit()
  1064. return settings
  1065. def _mock_spoolman_client(base_url: str = "http://spoolman.local:7912") -> MagicMock:
  1066. client = MagicMock()
  1067. client.base_url = base_url
  1068. client.get_spools = AsyncMock(return_value=[])
  1069. client.get_spool = AsyncMock(return_value={})
  1070. client.find_spool_by_tag = AsyncMock(return_value=None)
  1071. client.update_spool = AsyncMock(return_value=None)
  1072. client.merge_spool_extra = AsyncMock(return_value={"id": 0})
  1073. return client
  1074. def _spoolman_spool_fixture(spool_id: int, spool_weight: float = 196.0, filament_weight: float = 1000.0) -> dict:
  1075. """Build a minimal Spoolman spool dict with realistic core weight from filament.spool_weight."""
  1076. return {
  1077. "id": spool_id,
  1078. "filament": {"weight": filament_weight, "spool_weight": spool_weight},
  1079. "used_weight": 0.0,
  1080. }
  1081. class TestUpdateSpoolWeightSpoolman:
  1082. """update-spool-weight routes to Spoolman when Spoolman mode is active."""
  1083. @pytest.mark.asyncio
  1084. @pytest.mark.integration
  1085. async def test_spoolman_mode_uses_filament_spool_weight(self, async_client: AsyncClient, spoolman_settings):
  1086. """core_weight comes from filament.spool_weight, not a hardcoded constant."""
  1087. sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0)
  1088. mock_client = _mock_spoolman_client()
  1089. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1090. mock_client.update_spool = AsyncMock(return_value=sm_spool)
  1091. with (
  1092. patch(
  1093. "backend.app.services.spoolman.get_spoolman_client",
  1094. AsyncMock(return_value=mock_client),
  1095. ),
  1096. patch(
  1097. "backend.app.services.spoolman.init_spoolman_client",
  1098. AsyncMock(return_value=mock_client),
  1099. ),
  1100. ):
  1101. resp = await async_client.post(
  1102. f"{API}/scale/update-spool-weight",
  1103. json={"spool_id": 42, "weight_grams": 750},
  1104. )
  1105. assert resp.status_code == 200
  1106. data = resp.json()
  1107. assert data["status"] == "ok"
  1108. # remaining = max(0, 750 - 196) = 554 → weight_used = 1000 - 554 = 446
  1109. assert data["weight_used"] == pytest.approx(446.0)
  1110. mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(554.0))
  1111. @pytest.mark.asyncio
  1112. @pytest.mark.integration
  1113. async def test_spoolman_mode_clamps_remaining_to_zero(self, async_client: AsyncClient, spoolman_settings):
  1114. """Scale weight below core weight → remaining_weight = 0."""
  1115. sm_spool = _spoolman_spool_fixture(7, spool_weight=196.0, filament_weight=1000.0)
  1116. mock_client = _mock_spoolman_client()
  1117. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1118. mock_client.update_spool = AsyncMock(return_value=sm_spool)
  1119. with (
  1120. patch(
  1121. "backend.app.services.spoolman.get_spoolman_client",
  1122. AsyncMock(return_value=mock_client),
  1123. ),
  1124. patch(
  1125. "backend.app.services.spoolman.init_spoolman_client",
  1126. AsyncMock(return_value=mock_client),
  1127. ),
  1128. ):
  1129. resp = await async_client.post(
  1130. f"{API}/scale/update-spool-weight",
  1131. json={"spool_id": 7, "weight_grams": 100},
  1132. )
  1133. assert resp.status_code == 200
  1134. mock_client.update_spool.assert_called_once_with(spool_id=7, remaining_weight=0.0)
  1135. @pytest.mark.asyncio
  1136. @pytest.mark.integration
  1137. async def test_spoolman_mode_404_when_spool_not_found(self, async_client: AsyncClient, spoolman_settings):
  1138. """404 when Spoolman doesn't know the spool."""
  1139. mock_client = _mock_spoolman_client()
  1140. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found"))
  1141. with (
  1142. patch(
  1143. "backend.app.services.spoolman.get_spoolman_client",
  1144. AsyncMock(return_value=mock_client),
  1145. ),
  1146. patch(
  1147. "backend.app.services.spoolman.init_spoolman_client",
  1148. AsyncMock(return_value=mock_client),
  1149. ),
  1150. ):
  1151. resp = await async_client.post(
  1152. f"{API}/scale/update-spool-weight",
  1153. json={"spool_id": 9999, "weight_grams": 500},
  1154. )
  1155. assert resp.status_code == 404
  1156. @pytest.mark.asyncio
  1157. @pytest.mark.integration
  1158. async def test_spoolman_mode_502_on_client_failure(self, async_client: AsyncClient, spoolman_settings):
  1159. """502 is returned when Spoolman client update fails (returns None)."""
  1160. sm_spool = _spoolman_spool_fixture(99)
  1161. mock_client = _mock_spoolman_client()
  1162. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1163. mock_client.update_spool = AsyncMock(return_value=None)
  1164. with (
  1165. patch(
  1166. "backend.app.services.spoolman.get_spoolman_client",
  1167. AsyncMock(return_value=mock_client),
  1168. ),
  1169. patch(
  1170. "backend.app.services.spoolman.init_spoolman_client",
  1171. AsyncMock(return_value=mock_client),
  1172. ),
  1173. ):
  1174. resp = await async_client.post(
  1175. f"{API}/scale/update-spool-weight",
  1176. json={"spool_id": 99, "weight_grams": 500},
  1177. )
  1178. assert resp.status_code == 502
  1179. @pytest.mark.asyncio
  1180. @pytest.mark.integration
  1181. async def test_local_mode_unchanged(self, async_client: AsyncClient, spool_factory):
  1182. """When Spoolman is NOT enabled, local DB update still works."""
  1183. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  1184. resp = await async_client.post(
  1185. f"{API}/scale/update-spool-weight",
  1186. json={"spool_id": spool.id, "weight_grams": 750},
  1187. )
  1188. assert resp.status_code == 200
  1189. assert resp.json()["weight_used"] == 500
  1190. class TestTagScannedSpoolmanFallback:
  1191. """nfc/tag-scanned falls back to Spoolman when local DB has no match."""
  1192. @pytest.mark.asyncio
  1193. @pytest.mark.integration
  1194. async def test_spoolman_fallback_on_local_miss(self, async_client: AsyncClient, spoolman_settings):
  1195. raw_spool = {
  1196. "id": 5,
  1197. "filament": {
  1198. "material": "PETG",
  1199. "name": "PETG Basic",
  1200. "color_hex": "00FF00",
  1201. "weight": 1000,
  1202. "vendor": {"name": "Polymaker"},
  1203. },
  1204. "used_weight": 100.0,
  1205. "archived": False,
  1206. "registered": "2024-01-01T00:00:00+00:00",
  1207. "extra": {"tag": '"DEADBEEF12345678"'},
  1208. }
  1209. mock_client = _mock_spoolman_client()
  1210. mock_client.get_spools = AsyncMock(return_value=[raw_spool])
  1211. mock_client.find_spool_by_tag = AsyncMock(return_value=raw_spool)
  1212. with (
  1213. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1214. patch(
  1215. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  1216. new_callable=AsyncMock,
  1217. return_value=None,
  1218. ),
  1219. patch(
  1220. "backend.app.services.spoolman.get_spoolman_client",
  1221. AsyncMock(return_value=mock_client),
  1222. ),
  1223. patch(
  1224. "backend.app.services.spoolman.init_spoolman_client",
  1225. AsyncMock(return_value=mock_client),
  1226. ),
  1227. ):
  1228. mock_ws.broadcast = AsyncMock()
  1229. resp = await async_client.post(
  1230. f"{API}/nfc/tag-scanned",
  1231. json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"},
  1232. )
  1233. assert resp.status_code == 200
  1234. data = resp.json()
  1235. assert data["matched"] is True
  1236. assert data["spool_id"] == 5
  1237. mock_ws.broadcast.assert_called_once()
  1238. msg = mock_ws.broadcast.call_args[0][0]
  1239. assert msg["type"] == "spoolbuddy_tag_matched"
  1240. assert msg["spool"]["id"] == 5
  1241. assert msg["spool"]["material"] == "PETG"
  1242. @pytest.mark.asyncio
  1243. @pytest.mark.integration
  1244. async def test_spoolman_fallback_unknown_when_no_spoolman_match(self, async_client: AsyncClient, spoolman_settings):
  1245. """Unknown tag broadcast when both local DB and Spoolman miss."""
  1246. mock_client = _mock_spoolman_client()
  1247. mock_client.get_spools = AsyncMock(return_value=[])
  1248. mock_client.find_spool_by_tag = AsyncMock(return_value=None)
  1249. with (
  1250. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1251. patch(
  1252. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  1253. new_callable=AsyncMock,
  1254. return_value=None,
  1255. ),
  1256. patch(
  1257. "backend.app.services.spoolman.get_spoolman_client",
  1258. AsyncMock(return_value=mock_client),
  1259. ),
  1260. patch(
  1261. "backend.app.services.spoolman.init_spoolman_client",
  1262. AsyncMock(return_value=mock_client),
  1263. ),
  1264. ):
  1265. mock_ws.broadcast = AsyncMock()
  1266. resp = await async_client.post(
  1267. f"{API}/nfc/tag-scanned",
  1268. json={"device_id": "sb-1", "tag_uid": "UNKNOWN0000000FF"},
  1269. )
  1270. assert resp.status_code == 200
  1271. data = resp.json()
  1272. assert data["matched"] is False
  1273. assert data["spool_id"] is None
  1274. mock_ws.broadcast.assert_called_once()
  1275. msg = mock_ws.broadcast.call_args[0][0]
  1276. assert msg["type"] == "spoolbuddy_unknown_tag"
  1277. @pytest.mark.asyncio
  1278. @pytest.mark.integration
  1279. async def test_malformed_spoolman_data_degrades_gracefully(self, async_client: AsyncClient, spoolman_settings):
  1280. """ValueError from _map_spoolman_spool (e.g. spool_id=0) must return matched=False without broadcasting unknown_tag."""
  1281. bad_spool = {
  1282. "id": 0, # _map_spoolman_spool raises ValueError for id <= 0
  1283. "filament": {"material": "PLA", "name": "PLA Basic", "color_hex": "FF0000", "weight": 1000},
  1284. "used_weight": 0.0,
  1285. "archived": False,
  1286. "registered": "2024-01-01T00:00:00Z",
  1287. "extra": {"tag": '"DEADBEEF12345678"'},
  1288. }
  1289. mock_client = _mock_spoolman_client()
  1290. mock_client.find_spool_by_tag = AsyncMock(return_value=bad_spool)
  1291. with (
  1292. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1293. patch(
  1294. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  1295. new_callable=AsyncMock,
  1296. return_value=None,
  1297. ),
  1298. patch(
  1299. "backend.app.services.spoolman.get_spoolman_client",
  1300. AsyncMock(return_value=mock_client),
  1301. ),
  1302. patch(
  1303. "backend.app.services.spoolman.init_spoolman_client",
  1304. AsyncMock(return_value=mock_client),
  1305. ),
  1306. ):
  1307. mock_ws.broadcast = AsyncMock()
  1308. resp = await async_client.post(
  1309. f"{API}/nfc/tag-scanned",
  1310. json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"},
  1311. )
  1312. assert resp.status_code == 200
  1313. data = resp.json()
  1314. assert data["matched"] is False
  1315. assert data["spool_id"] is None
  1316. # No broadcast: UI must not get a spurious unknown_tag event on Spoolman data errors
  1317. mock_ws.broadcast.assert_not_called()
  1318. @pytest.mark.asyncio
  1319. @pytest.mark.integration
  1320. async def test_local_match_skips_spoolman(self, async_client: AsyncClient, spool_factory):
  1321. """When local DB matches, Spoolman is never queried."""
  1322. spool = await spool_factory(tag_uid="AABB1122", material="PLA")
  1323. mock_spool = MagicMock()
  1324. mock_spool.id = spool.id
  1325. mock_spool.material = spool.material
  1326. mock_spool.subtype = spool.subtype
  1327. mock_spool.color_name = spool.color_name
  1328. mock_spool.rgba = spool.rgba
  1329. mock_spool.brand = spool.brand
  1330. mock_spool.label_weight = spool.label_weight
  1331. mock_spool.core_weight = spool.core_weight
  1332. mock_spool.weight_used = spool.weight_used
  1333. with (
  1334. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1335. patch(
  1336. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  1337. new_callable=AsyncMock,
  1338. return_value=mock_spool,
  1339. ),
  1340. ):
  1341. mock_ws.broadcast = AsyncMock()
  1342. resp = await async_client.post(
  1343. f"{API}/nfc/tag-scanned",
  1344. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  1345. )
  1346. assert resp.status_code == 200
  1347. data = resp.json()
  1348. assert data["matched"] is True
  1349. assert data["spool_id"] == spool.id
  1350. # ============================================================================
  1351. # NFC write-tag / write-result — Spoolman-aware
  1352. # ============================================================================
  1353. def _full_spoolman_spool(spool_id: int) -> dict:
  1354. """Complete Spoolman spool dict sufficient for NDEF encoding."""
  1355. return {
  1356. "id": spool_id,
  1357. "filament": {
  1358. "material": "PLA",
  1359. "name": "PLA Basic",
  1360. "color_hex": "FF0000",
  1361. "weight": 1000.0,
  1362. "spool_weight": 196.0,
  1363. "vendor": {"name": "Bambu Lab"},
  1364. },
  1365. "used_weight": 0.0,
  1366. "archived": False,
  1367. "registered": "2024-01-01T00:00:00Z",
  1368. }
  1369. class TestNfcWriteTagSpoolman:
  1370. """nfc/write-tag falls back to Spoolman when local DB has no matching spool."""
  1371. @pytest.mark.asyncio
  1372. @pytest.mark.integration
  1373. async def test_spoolman_spool_queued_when_local_miss(
  1374. self, async_client: AsyncClient, device_factory, spoolman_settings
  1375. ):
  1376. """write-tag encodes NDEF from Spoolman data when spool not in local DB."""
  1377. await device_factory(device_id="sb-write-sm")
  1378. sm_spool = _full_spoolman_spool(77)
  1379. mock_client = _mock_spoolman_client()
  1380. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1381. with (
  1382. patch(
  1383. "backend.app.services.spoolman.get_spoolman_client",
  1384. AsyncMock(return_value=mock_client),
  1385. ),
  1386. patch(
  1387. "backend.app.services.spoolman.init_spoolman_client",
  1388. AsyncMock(return_value=mock_client),
  1389. ),
  1390. ):
  1391. resp = await async_client.post(
  1392. f"{API}/nfc/write-tag",
  1393. json={"device_id": "sb-write-sm", "spool_id": 77},
  1394. )
  1395. assert resp.status_code == 200
  1396. assert resp.json()["status"] == "queued"
  1397. mock_client.get_spool.assert_called_once_with(77)
  1398. @pytest.mark.asyncio
  1399. @pytest.mark.integration
  1400. async def test_data_origin_spoolman_stored_in_payload(
  1401. self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
  1402. ):
  1403. """Pending write payload records data_origin=spoolman for Spoolman spools."""
  1404. import json as _json
  1405. device = await device_factory(device_id="sb-origin")
  1406. sm_spool = _full_spoolman_spool(88)
  1407. mock_client = _mock_spoolman_client()
  1408. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1409. with (
  1410. patch(
  1411. "backend.app.services.spoolman.get_spoolman_client",
  1412. AsyncMock(return_value=mock_client),
  1413. ),
  1414. patch(
  1415. "backend.app.services.spoolman.init_spoolman_client",
  1416. AsyncMock(return_value=mock_client),
  1417. ),
  1418. ):
  1419. await async_client.post(
  1420. f"{API}/nfc/write-tag",
  1421. json={"device_id": "sb-origin", "spool_id": 88},
  1422. )
  1423. await db_session.refresh(device)
  1424. payload = _json.loads(device.pending_write_payload)
  1425. assert payload["data_origin"] == "spoolman"
  1426. assert payload["spool_id"] == 88
  1427. assert "ndef_data_hex" in payload
  1428. @pytest.mark.asyncio
  1429. @pytest.mark.integration
  1430. async def test_404_when_neither_local_nor_spoolman(
  1431. self, async_client: AsyncClient, device_factory, spoolman_settings
  1432. ):
  1433. """404 returned when spool is missing from both local DB and Spoolman."""
  1434. await device_factory(device_id="sb-miss")
  1435. mock_client = _mock_spoolman_client()
  1436. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found"))
  1437. with (
  1438. patch(
  1439. "backend.app.services.spoolman.get_spoolman_client",
  1440. AsyncMock(return_value=mock_client),
  1441. ),
  1442. patch(
  1443. "backend.app.services.spoolman.init_spoolman_client",
  1444. AsyncMock(return_value=mock_client),
  1445. ),
  1446. ):
  1447. resp = await async_client.post(
  1448. f"{API}/nfc/write-tag",
  1449. json={"device_id": "sb-miss", "spool_id": 9999},
  1450. )
  1451. assert resp.status_code == 404
  1452. @pytest.mark.asyncio
  1453. @pytest.mark.integration
  1454. async def test_local_spool_used_when_present(self, async_client: AsyncClient, device_factory, spool_factory):
  1455. """Local DB spool is encoded directly without contacting Spoolman."""
  1456. await device_factory(device_id="sb-local-write")
  1457. spool = await spool_factory(material="PETG")
  1458. resp = await async_client.post(
  1459. f"{API}/nfc/write-tag",
  1460. json={"device_id": "sb-local-write", "spool_id": spool.id},
  1461. )
  1462. assert resp.status_code == 200
  1463. assert resp.json()["status"] == "queued"
  1464. class TestNfcWriteResultSpoolman:
  1465. """nfc/write-result updates Spoolman extra.tag on success for Spoolman spools."""
  1466. @pytest.mark.asyncio
  1467. @pytest.mark.integration
  1468. async def test_success_updates_spoolman_extra_tag(
  1469. self, async_client: AsyncClient, device_factory, spoolman_settings
  1470. ):
  1471. """Successful write for a Spoolman spool calls merge_spool_extra with extra.tag."""
  1472. import json as _json
  1473. await device_factory(
  1474. device_id="sb-wr-sm",
  1475. pending_command="write_tag",
  1476. pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  1477. )
  1478. mock_client = _mock_spoolman_client()
  1479. mock_client.merge_spool_extra = AsyncMock(return_value={"id": 55})
  1480. with (
  1481. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1482. patch(
  1483. "backend.app.services.spoolman.get_spoolman_client",
  1484. AsyncMock(return_value=mock_client),
  1485. ),
  1486. patch(
  1487. "backend.app.services.spoolman.init_spoolman_client",
  1488. AsyncMock(return_value=mock_client),
  1489. ),
  1490. ):
  1491. mock_ws.broadcast = AsyncMock()
  1492. resp = await async_client.post(
  1493. f"{API}/nfc/write-result",
  1494. json={
  1495. "device_id": "sb-wr-sm",
  1496. "spool_id": 55,
  1497. "tag_uid": "AABBCCDD11223344",
  1498. "success": True,
  1499. },
  1500. )
  1501. assert resp.status_code == 200
  1502. mock_client.merge_spool_extra.assert_called_once_with(55, {"tag": '"AABBCCDD11223344"'})
  1503. msg = mock_ws.broadcast.call_args[0][0]
  1504. assert msg["type"] == "spoolbuddy_tag_written"
  1505. assert msg["tag_uid"] == "AABBCCDD11223344"
  1506. @pytest.mark.asyncio
  1507. @pytest.mark.integration
  1508. async def test_failure_does_not_call_spoolman(self, async_client: AsyncClient, device_factory, spoolman_settings):
  1509. """Failed write never calls Spoolman update."""
  1510. import json as _json
  1511. await device_factory(
  1512. device_id="sb-wr-fail",
  1513. pending_command="write_tag",
  1514. pending_write_payload=_json.dumps({"spool_id": 66, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  1515. )
  1516. mock_client = _mock_spoolman_client()
  1517. with (
  1518. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1519. patch(
  1520. "backend.app.services.spoolman.get_spoolman_client",
  1521. AsyncMock(return_value=mock_client),
  1522. ),
  1523. ):
  1524. mock_ws.broadcast = AsyncMock()
  1525. resp = await async_client.post(
  1526. f"{API}/nfc/write-result",
  1527. json={
  1528. "device_id": "sb-wr-fail",
  1529. "spool_id": 66,
  1530. "tag_uid": "AABBCCDD11223344",
  1531. "success": False,
  1532. "message": "write timeout",
  1533. },
  1534. )
  1535. assert resp.status_code == 200
  1536. mock_client.update_spool.assert_not_called()
  1537. msg = mock_ws.broadcast.call_args[0][0]
  1538. assert msg["type"] == "spoolbuddy_tag_write_failed"
  1539. @pytest.mark.asyncio
  1540. @pytest.mark.integration
  1541. async def test_success_local_spool_writes_to_db(
  1542. self, async_client: AsyncClient, device_factory, spool_factory, db_session
  1543. ):
  1544. """Successful write for a local spool still updates local DB tag_uid."""
  1545. import json as _json
  1546. spool = await spool_factory()
  1547. await device_factory(
  1548. device_id="sb-wr-local",
  1549. pending_command="write_tag",
  1550. pending_write_payload=_json.dumps(
  1551. {"spool_id": spool.id, "ndef_data_hex": "deadbeef", "data_origin": "local"}
  1552. ),
  1553. )
  1554. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1555. mock_ws.broadcast = AsyncMock()
  1556. resp = await async_client.post(
  1557. f"{API}/nfc/write-result",
  1558. json={
  1559. "device_id": "sb-wr-local",
  1560. "spool_id": spool.id,
  1561. "tag_uid": "DEADBEEF12345678",
  1562. "success": True,
  1563. },
  1564. )
  1565. assert resp.status_code == 200
  1566. await db_session.refresh(spool)
  1567. assert spool.tag_uid == "DEADBEEF12345678"
  1568. assert spool.tag_type == "ntag"
  1569. # ============================================================================
  1570. # Security fix tests — write-tag ValueError + write-result exception safety
  1571. # ============================================================================
  1572. class TestNfcWriteTagSpoolmanSecurityFixes:
  1573. """Regression tests for security fixes in nfc/write-tag Spoolman path."""
  1574. @pytest.mark.asyncio
  1575. @pytest.mark.integration
  1576. async def test_invalid_spoolman_spool_id_returns_502(
  1577. self, async_client: AsyncClient, device_factory, spoolman_settings
  1578. ):
  1579. """Malformed Spoolman spool (invalid id=0) raises 502, not 404 — spool exists but is bad data."""
  1580. await device_factory(device_id="sb-invalid-id")
  1581. # Spoolman returns spool with id=0 (invalid — caught by _map_spoolman_spool guard)
  1582. bad_spool = {**_full_spoolman_spool(1), "id": 0}
  1583. mock_client = _mock_spoolman_client()
  1584. mock_client.get_spool = AsyncMock(return_value=bad_spool)
  1585. with (
  1586. patch(
  1587. "backend.app.services.spoolman.get_spoolman_client",
  1588. AsyncMock(return_value=mock_client),
  1589. ),
  1590. patch(
  1591. "backend.app.services.spoolman.init_spoolman_client",
  1592. AsyncMock(return_value=mock_client),
  1593. ),
  1594. ):
  1595. resp = await async_client.post(
  1596. f"{API}/nfc/write-tag",
  1597. json={"device_id": "sb-invalid-id", "spool_id": 99},
  1598. )
  1599. # 502: spool exists in Spoolman but its data is malformed — not a "not found"
  1600. assert resp.status_code == 502
  1601. @pytest.mark.asyncio
  1602. @pytest.mark.integration
  1603. async def test_oversized_label_weight_does_not_crash(
  1604. self, async_client: AsyncClient, device_factory, spoolman_settings
  1605. ):
  1606. """label_weight > 65535 from Spoolman must not crash with struct.error."""
  1607. await device_factory(device_id="sb-overflow")
  1608. big_weight_spool = {
  1609. **_full_spoolman_spool(42),
  1610. "filament": {**_full_spoolman_spool(42)["filament"], "weight": 70000},
  1611. }
  1612. mock_client = _mock_spoolman_client()
  1613. mock_client.get_spool = AsyncMock(return_value=big_weight_spool)
  1614. with (
  1615. patch(
  1616. "backend.app.services.spoolman.get_spoolman_client",
  1617. AsyncMock(return_value=mock_client),
  1618. ),
  1619. patch(
  1620. "backend.app.services.spoolman.init_spoolman_client",
  1621. AsyncMock(return_value=mock_client),
  1622. ),
  1623. ):
  1624. resp = await async_client.post(
  1625. f"{API}/nfc/write-tag",
  1626. json={"device_id": "sb-overflow", "spool_id": 42},
  1627. )
  1628. assert resp.status_code == 200
  1629. assert resp.json()["status"] == "queued"
  1630. class TestNfcWriteResultSpoolmanSecurityFixes:
  1631. """Regression tests for transaction safety in nfc/write-result Spoolman path."""
  1632. @pytest.mark.asyncio
  1633. @pytest.mark.integration
  1634. async def test_spoolman_client_exception_still_clears_device_state(
  1635. self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
  1636. ):
  1637. """If Spoolman client raises, device pending_command is still cleared in DB."""
  1638. import json as _json
  1639. device = await device_factory(
  1640. device_id="sb-exc-safe",
  1641. pending_command="write_tag",
  1642. pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  1643. )
  1644. mock_client = _mock_spoolman_client()
  1645. mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused"))
  1646. with (
  1647. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1648. patch(
  1649. "backend.app.services.spoolman.get_spoolman_client",
  1650. AsyncMock(return_value=mock_client),
  1651. ),
  1652. patch(
  1653. "backend.app.services.spoolman.init_spoolman_client",
  1654. AsyncMock(return_value=mock_client),
  1655. ),
  1656. ):
  1657. mock_ws.broadcast = AsyncMock()
  1658. resp = await async_client.post(
  1659. f"{API}/nfc/write-result",
  1660. json={
  1661. "device_id": "sb-exc-safe",
  1662. "spool_id": 77,
  1663. "tag_uid": "AABBCCDD11223344",
  1664. "success": True,
  1665. },
  1666. )
  1667. # 502: tag written to NFC but Spoolman link failed (not best-effort — caller must retry)
  1668. assert resp.status_code == 502
  1669. # Device state must be cleared despite the exception (no spurious re-write)
  1670. await db_session.refresh(device)
  1671. assert device.pending_command is None
  1672. assert device.pending_write_payload is None
  1673. # Failure broadcast fires so the UI can show the error
  1674. msg = mock_ws.broadcast.call_args[0][0]
  1675. assert msg["type"] == "spoolbuddy_tag_link_failed"
  1676. @pytest.mark.asyncio
  1677. @pytest.mark.integration
  1678. async def test_spoolman_not_found_error_broadcasts_link_failed(
  1679. self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
  1680. ):
  1681. """SpoolmanNotFoundError from merge_spool_extra must clear device state and broadcast link_failed."""
  1682. import json as _json
  1683. device = await device_factory(
  1684. device_id="sb-notfound",
  1685. pending_command="write_tag",
  1686. pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  1687. )
  1688. mock_client = _mock_spoolman_client()
  1689. mock_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 55 not found"))
  1690. with (
  1691. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1692. patch(
  1693. "backend.app.services.spoolman.get_spoolman_client",
  1694. AsyncMock(return_value=mock_client),
  1695. ),
  1696. patch(
  1697. "backend.app.services.spoolman.init_spoolman_client",
  1698. AsyncMock(return_value=mock_client),
  1699. ),
  1700. ):
  1701. mock_ws.broadcast = AsyncMock()
  1702. resp = await async_client.post(
  1703. f"{API}/nfc/write-result",
  1704. json={
  1705. "device_id": "sb-notfound",
  1706. "spool_id": 55,
  1707. "tag_uid": "AABBCCDD11223344",
  1708. "success": True,
  1709. },
  1710. )
  1711. assert resp.status_code == 502
  1712. await db_session.refresh(device)
  1713. assert device.pending_command is None
  1714. assert device.pending_write_payload is None
  1715. msg = mock_ws.broadcast.call_args[0][0]
  1716. assert msg["type"] == "spoolbuddy_tag_link_failed"
  1717. assert msg["spool_id"] == 55
  1718. class TestNfcWriteResultOrphanedSpool:
  1719. """nfc/write-result when the local spool was deleted between write-queue and write-result."""
  1720. @pytest.mark.asyncio
  1721. @pytest.mark.integration
  1722. async def test_local_spool_deleted_before_write_back(self, async_client: AsyncClient, device_factory, db_session):
  1723. """When local spool is deleted between write-queue and write-result, return linked=False and broadcast link_failed."""
  1724. import json as _json
  1725. device = await device_factory(
  1726. device_id="sb-orphan",
  1727. pending_command="write_tag",
  1728. pending_write_payload=_json.dumps(
  1729. {
  1730. "spool_id": 99999, # non-existent spool
  1731. "ndef_data_hex": "aabbccdd",
  1732. "data_origin": "local",
  1733. }
  1734. ),
  1735. )
  1736. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1737. mock_ws.broadcast = AsyncMock()
  1738. resp = await async_client.post(
  1739. f"{API}/nfc/write-result",
  1740. json={"device_id": device.device_id, "spool_id": 99999, "success": True, "tag_uid": "AABBCCDD"},
  1741. )
  1742. assert resp.status_code == 200
  1743. data = resp.json()
  1744. assert data["linked"] is False
  1745. # pending command should be cleared
  1746. await db_session.refresh(device)
  1747. assert device.pending_command is None
  1748. # broadcast should be spoolbuddy_tag_link_failed
  1749. broadcast_calls = mock_ws.broadcast.call_args_list
  1750. link_failed = [c[0][0] for c in broadcast_calls if c[0][0].get("type") == "spoolbuddy_tag_link_failed"]
  1751. assert len(link_failed) >= 1
  1752. class TestNfcWriteResultInputValidation:
  1753. """Input validation and JSON safety for nfc/write-result."""
  1754. @pytest.mark.asyncio
  1755. @pytest.mark.integration
  1756. async def test_tag_uid_too_long_rejected(self, async_client: AsyncClient, device_factory):
  1757. """tag_uid longer than 32 chars must be rejected with 422."""
  1758. import json as _json
  1759. await device_factory(
  1760. device_id="sb-uid-long",
  1761. pending_command="write_tag",
  1762. pending_write_payload=_json.dumps({"spool_id": 1, "ndef_data_hex": "dead", "data_origin": "local"}),
  1763. )
  1764. resp = await async_client.post(
  1765. f"{API}/nfc/write-result",
  1766. json={
  1767. "device_id": "sb-uid-long",
  1768. "spool_id": 1,
  1769. "tag_uid": "A" * 65,
  1770. "success": True,
  1771. },
  1772. )
  1773. assert resp.status_code == 422
  1774. @pytest.mark.asyncio
  1775. @pytest.mark.integration
  1776. async def test_malformed_pending_payload_falls_back_to_local(
  1777. self, async_client: AsyncClient, device_factory, spool_factory, db_session
  1778. ):
  1779. """Corrupted pending_write_payload JSON falls back to local mode gracefully."""
  1780. spool = await spool_factory()
  1781. await device_factory(
  1782. device_id="sb-corrupt-json",
  1783. pending_command="write_tag",
  1784. pending_write_payload="{not valid json!!!",
  1785. )
  1786. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1787. mock_ws.broadcast = AsyncMock()
  1788. resp = await async_client.post(
  1789. f"{API}/nfc/write-result",
  1790. json={
  1791. "device_id": "sb-corrupt-json",
  1792. "spool_id": spool.id,
  1793. "tag_uid": "DEADBEEF12345678",
  1794. "success": True,
  1795. },
  1796. )
  1797. # Must return 200, not 500
  1798. assert resp.status_code == 200
  1799. # Falls back to local mode — tag written to DB
  1800. await db_session.refresh(spool)
  1801. assert spool.tag_uid == "DEADBEEF12345678"
  1802. # ============================================================================
  1803. # B1: NFC write-tag warnings appear in response body
  1804. # ============================================================================
  1805. class TestNfcWriteTagWarningsBody:
  1806. """B1: resp.json()['warnings'] is populated when Spoolman fields are absent."""
  1807. @pytest.mark.asyncio
  1808. @pytest.mark.integration
  1809. async def test_warnings_returned_for_missing_color_and_temp(
  1810. self, async_client: AsyncClient, device_factory, spoolman_settings
  1811. ):
  1812. """Both color_name=None and settings_extruder_temp=None produce 2 warnings."""
  1813. await device_factory(device_id="sb-warn-b1")
  1814. # Spoolman spool with no color_name or nozzle temp
  1815. sparse_spool = {
  1816. "id": 99,
  1817. "filament": {
  1818. "material": "PLA",
  1819. "name": "PLA Basic",
  1820. "color_hex": "808080",
  1821. # color_name absent → None after mapping
  1822. # settings_extruder_temp absent → nozzle_temp_min=None
  1823. "weight": 1000.0,
  1824. "vendor": {"name": "Bambu Lab"},
  1825. },
  1826. "used_weight": 0.0,
  1827. "archived": False,
  1828. "registered": "2024-01-01T00:00:00Z",
  1829. }
  1830. mock_client = _mock_spoolman_client()
  1831. mock_client.get_spool = AsyncMock(return_value=sparse_spool)
  1832. with (
  1833. patch(
  1834. "backend.app.services.spoolman.get_spoolman_client",
  1835. AsyncMock(return_value=mock_client),
  1836. ),
  1837. patch(
  1838. "backend.app.services.spoolman.init_spoolman_client",
  1839. AsyncMock(return_value=mock_client),
  1840. ),
  1841. ):
  1842. resp = await async_client.post(
  1843. f"{API}/nfc/write-tag",
  1844. json={"device_id": "sb-warn-b1", "spool_id": 99},
  1845. )
  1846. assert resp.status_code == 200
  1847. body = resp.json()
  1848. assert "warnings" in body, "Response should contain 'warnings' key when fields are absent"
  1849. warnings = body["warnings"]
  1850. assert len(warnings) >= 2, f"Expected at least 2 warnings for missing color_name + nozzle_temp, got: {warnings}"
  1851. # Confirm the specific fields are mentioned
  1852. warn_text = " ".join(warnings)
  1853. assert "color_name" in warn_text
  1854. assert "nozzle_temp" in warn_text
  1855. @pytest.mark.asyncio
  1856. @pytest.mark.integration
  1857. async def test_no_warnings_key_when_all_fields_present(
  1858. self, async_client: AsyncClient, device_factory, spoolman_settings
  1859. ):
  1860. """No 'warnings' key in response when all fields are populated."""
  1861. await device_factory(device_id="sb-nowarn")
  1862. full_spool = _full_spoolman_spool(100)
  1863. # Add color_name and extruder temp
  1864. full_spool["filament"]["color_name"] = "Red"
  1865. full_spool["filament"]["settings_extruder_temp"] = 220
  1866. mock_client = _mock_spoolman_client()
  1867. mock_client.get_spool = AsyncMock(return_value=full_spool)
  1868. with (
  1869. patch(
  1870. "backend.app.services.spoolman.get_spoolman_client",
  1871. AsyncMock(return_value=mock_client),
  1872. ),
  1873. patch(
  1874. "backend.app.services.spoolman.init_spoolman_client",
  1875. AsyncMock(return_value=mock_client),
  1876. ),
  1877. ):
  1878. resp = await async_client.post(
  1879. f"{API}/nfc/write-tag",
  1880. json={"device_id": "sb-nowarn", "spool_id": 100},
  1881. )
  1882. assert resp.status_code == 200
  1883. body = resp.json()
  1884. assert "warnings" not in body or body["warnings"] == []
  1885. # ============================================================================
  1886. # B5: Exception text scrubbed from WebSocket broadcast message
  1887. # ============================================================================
  1888. class TestNfcWriteResultExceptionScrubbing:
  1889. """B5: Internal exception details must not appear in WebSocket 'message' field."""
  1890. @pytest.mark.asyncio
  1891. @pytest.mark.integration
  1892. async def test_exception_text_not_leaked_in_ws_message(
  1893. self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
  1894. ):
  1895. """When Spoolman merge raises, WS message is generic; 'connection refused' absent."""
  1896. import json as _json
  1897. await device_factory(
  1898. device_id="sb-scrub-b5",
  1899. pending_command="write_tag",
  1900. pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  1901. )
  1902. mock_client = _mock_spoolman_client()
  1903. mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused to 192.168.1.1:7912"))
  1904. with (
  1905. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1906. patch(
  1907. "backend.app.services.spoolman.get_spoolman_client",
  1908. AsyncMock(return_value=mock_client),
  1909. ),
  1910. patch(
  1911. "backend.app.services.spoolman.init_spoolman_client",
  1912. AsyncMock(return_value=mock_client),
  1913. ),
  1914. ):
  1915. mock_ws.broadcast = AsyncMock()
  1916. resp = await async_client.post(
  1917. f"{API}/nfc/write-result",
  1918. json={
  1919. "device_id": "sb-scrub-b5",
  1920. "spool_id": 77,
  1921. "tag_uid": "AABBCCDD11223344",
  1922. "success": True,
  1923. },
  1924. )
  1925. assert resp.status_code == 502
  1926. msg = mock_ws.broadcast.call_args[0][0]
  1927. assert msg["type"] == "spoolbuddy_tag_link_failed"
  1928. # Generic message — no internal exception details leaked
  1929. assert msg["message"] == "Spoolman link failed", f"Expected generic message but got: {msg['message']!r}"
  1930. assert "connection refused" not in str(msg), f"Exception text must not appear in WS message: {msg}"
  1931. assert "192.168.1" not in str(msg), f"Internal IP must not appear in WS message: {msg}"
  1932. # ============================================================================
  1933. # _get_spoolman_client_or_none: graceful degradation on ValueError during reinit
  1934. # ============================================================================
  1935. class TestSpoolmanClientOrNoneGraceful:
  1936. """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError."""
  1937. @pytest.mark.asyncio
  1938. @pytest.mark.integration
  1939. async def test_returns_none_when_init_raises_value_error(self, async_client: AsyncClient, db_session):
  1940. """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError,
  1941. so the device endpoint degrades gracefully instead of propagating a 500 error."""
  1942. from backend.app.models.settings import Settings
  1943. db_session.add(Settings(key="spoolman_enabled", value="true"))
  1944. db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
  1945. await db_session.commit()
  1946. with (
  1947. patch("backend.app.api.routes._spoolman_helpers.assert_safe_spoolman_url"),
  1948. patch(
  1949. "backend.app.services.spoolman.get_spoolman_client",
  1950. AsyncMock(return_value=None),
  1951. ),
  1952. patch(
  1953. "backend.app.services.spoolman.init_spoolman_client",
  1954. AsyncMock(side_effect=ValueError("invalid URL")),
  1955. ),
  1956. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1957. ):
  1958. mock_ws.broadcast = AsyncMock()
  1959. # nfc/tag-scanned calls _get_spoolman_client_or_none; with None returned it
  1960. # must broadcast unknown_tag (not raise 500 due to ValueError propagating).
  1961. resp = await async_client.post(
  1962. f"{API}/nfc/tag-scanned",
  1963. json={"device_id": "sb-vale", "tag_uid": "AABBCCDD"},
  1964. )
  1965. # Must not be 500 — ValueError is caught and client returns None, degrading gracefully
  1966. assert resp.status_code == 200
  1967. data = resp.json()
  1968. assert data["matched"] is False