test_spoolbuddy.py 114 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843
  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_ssh_public_key(self, async_client: AsyncClient, device_factory):
  179. """Heartbeat response carries the current SSH public key so the daemon
  180. can re-deploy it whenever Bambuddy's keypair rotates without waiting
  181. for a service restart."""
  182. await device_factory(device_id="sb-ssh-hb")
  183. fake_key = "ssh-ed25519 AAAATESTKEY bambuddy-spoolbuddy"
  184. with (
  185. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  186. patch(
  187. "backend.app.services.spoolbuddy_ssh.get_public_key",
  188. AsyncMock(return_value=fake_key),
  189. ),
  190. ):
  191. mock_ws.broadcast = AsyncMock()
  192. resp = await async_client.post(
  193. f"{API}/devices/sb-ssh-hb/heartbeat",
  194. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  195. )
  196. assert resp.status_code == 200
  197. assert resp.json()["ssh_public_key"] == fake_key
  198. @pytest.mark.asyncio
  199. @pytest.mark.integration
  200. async def test_heartbeat_ssh_key_failure_does_not_break_heartbeat(self, async_client: AsyncClient, device_factory):
  201. """If the backend can't read its own SSH key, the heartbeat must still
  202. succeed — telemetry/commands are far more critical than key sync."""
  203. await device_factory(device_id="sb-ssh-fail")
  204. with (
  205. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  206. patch(
  207. "backend.app.services.spoolbuddy_ssh.get_public_key",
  208. AsyncMock(side_effect=OSError("disk full")),
  209. ),
  210. ):
  211. mock_ws.broadcast = AsyncMock()
  212. resp = await async_client.post(
  213. f"{API}/devices/sb-ssh-fail/heartbeat",
  214. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  215. )
  216. assert resp.status_code == 200
  217. assert resp.json()["ssh_public_key"] is None
  218. @pytest.mark.asyncio
  219. @pytest.mark.integration
  220. async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
  221. await device_factory(device_id="sb-cmd", pending_command="tare")
  222. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  223. mock_ws.broadcast = AsyncMock()
  224. resp = await async_client.post(
  225. f"{API}/devices/sb-cmd/heartbeat",
  226. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  227. )
  228. assert resp.status_code == 200
  229. assert resp.json()["pending_command"] == "tare"
  230. # Second heartbeat should have no pending command (cleared)
  231. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  232. mock_ws.broadcast = AsyncMock()
  233. resp2 = await async_client.post(
  234. f"{API}/devices/sb-cmd/heartbeat",
  235. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  236. )
  237. assert resp2.json()["pending_command"] is None
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
  241. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  242. mock_ws.broadcast = AsyncMock()
  243. resp = await async_client.post(
  244. f"{API}/devices/nonexistent/heartbeat",
  245. json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
  246. )
  247. assert resp.status_code == 404
  248. @pytest.mark.asyncio
  249. @pytest.mark.integration
  250. async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
  251. # Create device with last_seen far in the past (offline)
  252. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  253. await device_factory(
  254. device_id="sb-offline",
  255. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  256. )
  257. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  258. mock_ws.broadcast = AsyncMock()
  259. resp = await async_client.post(
  260. f"{API}/devices/sb-offline/heartbeat",
  261. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
  262. )
  263. assert resp.status_code == 200
  264. # Should broadcast online since device was offline
  265. mock_ws.broadcast.assert_called_once()
  266. msg = mock_ws.broadcast.call_args[0][0]
  267. assert msg["type"] == "spoolbuddy_online"
  268. assert msg["device_id"] == "sb-offline"
  269. @pytest.mark.asyncio
  270. @pytest.mark.integration
  271. async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory):
  272. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  273. await device_factory(
  274. device_id="sb-already-online",
  275. last_seen=datetime.now(timezone.utc),
  276. )
  277. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  278. mock_ws.broadcast = AsyncMock()
  279. resp = await async_client.post(
  280. f"{API}/devices/sb-already-online/heartbeat",
  281. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 42},
  282. )
  283. assert resp.status_code == 200
  284. mock_ws.broadcast.assert_called_once()
  285. msg = mock_ws.broadcast.call_args[0][0]
  286. assert msg["type"] == "spoolbuddy_online"
  287. assert msg["device_id"] == "sb-already-online"
  288. @pytest.mark.asyncio
  289. @pytest.mark.integration
  290. async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory):
  291. spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
  292. await device_factory(
  293. device_id="sb-throttle",
  294. last_seen=datetime.now(timezone.utc),
  295. )
  296. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  297. mock_ws.broadcast = AsyncMock()
  298. resp1 = await async_client.post(
  299. f"{API}/devices/sb-throttle/heartbeat",
  300. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  301. )
  302. resp2 = await async_client.post(
  303. f"{API}/devices/sb-throttle/heartbeat",
  304. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 11},
  305. )
  306. assert resp1.status_code == 200
  307. assert resp2.status_code == 200
  308. mock_ws.broadcast.assert_called_once()
  309. msg = mock_ws.broadcast.call_args[0][0]
  310. assert msg["type"] == "spoolbuddy_online"
  311. assert msg["device_id"] == "sb-throttle"
  312. # ============================================================================
  313. # NFC endpoints
  314. # ============================================================================
  315. class TestNfcEndpoints:
  316. @pytest.mark.asyncio
  317. @pytest.mark.integration
  318. async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
  319. spool = await spool_factory(tag_uid="AABB1122", material="PLA")
  320. mock_spool = MagicMock()
  321. mock_spool.id = spool.id
  322. mock_spool.material = spool.material
  323. mock_spool.subtype = spool.subtype
  324. mock_spool.color_name = spool.color_name
  325. mock_spool.rgba = spool.rgba
  326. mock_spool.brand = spool.brand
  327. mock_spool.label_weight = spool.label_weight
  328. mock_spool.core_weight = spool.core_weight
  329. mock_spool.weight_used = spool.weight_used
  330. with (
  331. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  332. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  333. ):
  334. mock_ws.broadcast = AsyncMock()
  335. mock_lookup.return_value = mock_spool
  336. resp = await async_client.post(
  337. f"{API}/nfc/tag-scanned",
  338. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  339. )
  340. assert resp.status_code == 200
  341. data = resp.json()
  342. assert data["matched"] is True
  343. assert data["spool_id"] == spool.id
  344. msg = mock_ws.broadcast.call_args[0][0]
  345. assert msg["type"] == "spoolbuddy_tag_matched"
  346. assert msg["spool"]["id"] == spool.id
  347. @pytest.mark.asyncio
  348. @pytest.mark.integration
  349. async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
  350. with (
  351. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  352. patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock) as mock_lookup,
  353. ):
  354. mock_ws.broadcast = AsyncMock()
  355. mock_lookup.return_value = None
  356. resp = await async_client.post(
  357. f"{API}/nfc/tag-scanned",
  358. json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
  359. )
  360. assert resp.status_code == 200
  361. data = resp.json()
  362. assert data["matched"] is False
  363. assert data["spool_id"] is None
  364. msg = mock_ws.broadcast.call_args[0][0]
  365. assert msg["type"] == "spoolbuddy_unknown_tag"
  366. @pytest.mark.asyncio
  367. @pytest.mark.integration
  368. async def test_tag_scanned_spoolman_mode_skips_local_lookup(self, async_client: AsyncClient, db_session):
  369. """When spoolman_enabled=true, /nfc/tag-scanned must use Spoolman
  370. exclusively — local DB lookup must not be consulted at all. The
  371. previous always-local-first behaviour caused stale local rows to
  372. win over the authoritative Spoolman data (#1228 follow-up).
  373. """
  374. from backend.app.models.settings import Settings
  375. db_session.add(Settings(key="spoolman_enabled", value="true"))
  376. db_session.add(Settings(key="spoolman_url", value="http://127.0.0.1:7912"))
  377. await db_session.commit()
  378. # Mock Spoolman match and verify get_spool_by_tag (the local-DB lookup)
  379. # is never called in Spoolman-enabled mode.
  380. sm_match = {
  381. "id": 7,
  382. "filament": {
  383. "material": "PLA",
  384. "name": "PLA Basic Red",
  385. "color_hex": "FF0000",
  386. "weight": 1000.0,
  387. "vendor": {"name": "Bambu Lab"},
  388. },
  389. "extra": {"tag": '"AABB1122"'},
  390. "used_weight": 0.0,
  391. }
  392. mock_client = MagicMock()
  393. mock_client.has_tag_api = AsyncMock(return_value=False)
  394. mock_client.add_native_tags = AsyncMock(return_value=0)
  395. mock_client.unlink_all_native_tags = AsyncMock()
  396. mock_client.get_spools = AsyncMock(return_value=[sm_match])
  397. mock_client.find_spool_by_tag = AsyncMock(return_value=sm_match)
  398. with (
  399. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  400. patch(
  401. "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
  402. new_callable=AsyncMock,
  403. ) as mock_get_client,
  404. patch(
  405. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  406. new_callable=AsyncMock,
  407. ) as mock_local_lookup,
  408. ):
  409. mock_ws.broadcast = AsyncMock()
  410. mock_get_client.return_value = mock_client
  411. # Sentinel so a misrouted call would surface as a wrong spool_id.
  412. mock_local_lookup.return_value = MagicMock(id=999)
  413. resp = await async_client.post(
  414. f"{API}/nfc/tag-scanned",
  415. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  416. )
  417. assert resp.status_code == 200
  418. data = resp.json()
  419. assert data["matched"] is True
  420. # Spoolman result, not local DB sentinel — proves the local lookup was skipped.
  421. assert data["spool_id"] == 7
  422. mock_local_lookup.assert_not_called()
  423. @pytest.mark.asyncio
  424. @pytest.mark.integration
  425. async def test_write_result_clears_duplicate_tag_binding(
  426. self, async_client: AsyncClient, db_session, device_factory
  427. ):
  428. """Writing a tag for spool B must clear the same tag binding from any
  429. other spool that currently has it. Without this guard, find_spool_by_tag
  430. returns whichever spool comes first in the cached list (typically the
  431. older one), so the dashboard shows the wrong spool when the tag is
  432. scanned.
  433. """
  434. import json as _json
  435. from backend.app.models.settings import Settings
  436. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  437. db_session.add(Settings(key="spoolman_enabled", value="true"))
  438. db_session.add(Settings(key="spoolman_url", value="http://127.0.0.1:7912"))
  439. await device_factory(
  440. device_id="sb-write",
  441. pending_command="write_tag",
  442. pending_write_payload=_json.dumps({"spool_id": 22, "ndef_data_hex": "DEAD", "data_origin": "spoolman"}),
  443. )
  444. await db_session.commit()
  445. # Spool A (id=11) currently holds the tag we're about to bind to spool B (id=22).
  446. spool_a_with_tag = {
  447. "id": 11,
  448. "filament": {"material": "PLA", "name": "PLA Old", "color_hex": "AAAAAA", "weight": 1000.0},
  449. "extra": {"tag": '"DEADBEEF"'},
  450. }
  451. mock_client = MagicMock()
  452. mock_client.has_tag_api = AsyncMock(return_value=False)
  453. mock_client.add_native_tags = AsyncMock(return_value=0)
  454. mock_client.unlink_all_native_tags = AsyncMock()
  455. mock_client.get_spools = AsyncMock(return_value=[spool_a_with_tag])
  456. mock_client.find_spool_by_tag = AsyncMock(return_value=spool_a_with_tag)
  457. mock_client.merge_spool_extra = AsyncMock(return_value={})
  458. with (
  459. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  460. patch(
  461. "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
  462. new_callable=AsyncMock,
  463. ) as mock_get_client,
  464. ):
  465. mock_ws.broadcast = AsyncMock()
  466. mock_get_client.return_value = mock_client
  467. resp = await async_client.post(
  468. f"{API}/nfc/write-result",
  469. json={
  470. "device_id": "sb-write",
  471. "spool_id": 22,
  472. "tag_uid": "DEADBEEF",
  473. "success": True,
  474. },
  475. )
  476. assert resp.status_code == 200
  477. # merge_spool_extra was called twice:
  478. # 1. clear tag from spool A (id=11) — JSON-encoded "", as Spoolman
  479. # refuses a bare "" (#3312)
  480. # 2. set tag on spool B (id=22) — set tag to "DEADBEEF" (JSON-encoded)
  481. assert mock_client.merge_spool_extra.await_count == 2
  482. clear_call, bind_call = mock_client.merge_spool_extra.await_args_list
  483. assert clear_call.args[0] == 11
  484. assert clear_call.args[1] == {"tag": _json.dumps("")}
  485. assert bind_call.args[0] == 22
  486. assert bind_call.args[1] == {"tag": '"DEADBEEF"'}
  487. @pytest.mark.asyncio
  488. @pytest.mark.integration
  489. async def test_tag_scanned_other_tag_matches_ams_spool_by_tray_uuid(self, async_client: AsyncClient, spool_factory):
  490. """A Bambu spool has two tags with different UIDs but one block-9 tray UUID.
  491. The AMS created the spool from one tag; scanning the other tag on
  492. SpoolBuddy must find that spool through the tray UUID, not offer a
  493. duplicate (#984).
  494. """
  495. spool = await spool_factory(tag_uid="1E783DA000000100", tray_uuid="5E5498918CBF4B94A25EF669C24DECC3")
  496. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  497. mock_ws.broadcast = AsyncMock()
  498. resp = await async_client.post(
  499. f"{API}/nfc/tag-scanned",
  500. json={"device_id": "sb-1", "tag_uid": "8E3A00A2", "tray_uuid": "5E5498918CBF4B94A25EF669C24DECC3"},
  501. )
  502. assert resp.status_code == 200
  503. assert resp.json()["spool_id"] == spool.id
  504. msg = mock_ws.broadcast.call_args[0][0]
  505. assert msg["type"] == "spoolbuddy_tag_matched"
  506. assert msg["tray_uuid"] == "5E5498918CBF4B94A25EF669C24DECC3"
  507. @pytest.mark.asyncio
  508. @pytest.mark.integration
  509. async def test_tag_scanned_unknown_tag_carries_tray_uuid(self, async_client: AsyncClient):
  510. """The kiosk saves the tray UUID from the unknown-tag event on quick-add and link."""
  511. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  512. mock_ws.broadcast = AsyncMock()
  513. resp = await async_client.post(
  514. f"{API}/nfc/tag-scanned",
  515. json={"device_id": "sb-1", "tag_uid": "8E3A00A2", "tray_uuid": "9e0b0717bee94d7887eb1d8dfd1a14f3"},
  516. )
  517. assert resp.json()["matched"] is False
  518. msg = mock_ws.broadcast.call_args[0][0]
  519. assert msg["type"] == "spoolbuddy_unknown_tag"
  520. assert msg["tray_uuid"] == "9E0B0717BEE94D7887EB1D8DFD1A14F3"
  521. @pytest.mark.asyncio
  522. @pytest.mark.integration
  523. async def test_tag_scanned_drops_filament_type_sent_as_tray_uuid(self, async_client: AsyncClient, spool_factory):
  524. """Daemons before #984 sent blocks 4-5 -- the filament type -- as tray_uuid.
  525. That value is the same for every spool of one type, so it must neither
  526. match a spool nor reach the kiosk, where quick-add would save it.
  527. """
  528. pla_matte = "504C41204D6174746500000000000000" # "PLA Matte"
  529. await spool_factory(tag_uid="11111111", tray_uuid=pla_matte)
  530. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  531. mock_ws.broadcast = AsyncMock()
  532. resp = await async_client.post(
  533. f"{API}/nfc/tag-scanned",
  534. json={"device_id": "sb-1", "tag_uid": "22222222", "tray_uuid": pla_matte},
  535. )
  536. assert resp.json()["matched"] is False
  537. msg = mock_ws.broadcast.call_args[0][0]
  538. assert msg["type"] == "spoolbuddy_unknown_tag"
  539. assert msg["tray_uuid"] is None
  540. @pytest.mark.asyncio
  541. @pytest.mark.integration
  542. async def test_tag_scanned_drops_invalid_tray_uuid_but_matches_tag_uid(
  543. self, async_client: AsyncClient, spool_factory
  544. ):
  545. """An old daemon still matches by tag_uid, as before the fix."""
  546. spool = await spool_factory(tag_uid="22222222")
  547. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  548. mock_ws.broadcast = AsyncMock()
  549. resp = await async_client.post(
  550. f"{API}/nfc/tag-scanned",
  551. json={"device_id": "sb-1", "tag_uid": "22222222", "tray_uuid": "504C41204D6174746500000000000000"},
  552. )
  553. assert resp.json()["spool_id"] == spool.id
  554. assert mock_ws.broadcast.call_args[0][0]["tray_uuid"] is None
  555. # Spools added on the kiosk before #984 carry only one tag's UID. A scan that
  556. # matches one by that exact UID saves the tray UUID read from the same tag.
  557. TRAY = "9E0B0717BEE94D7887EB1D8DFD1A14F3"
  558. async def _scan(self, async_client: AsyncClient, tag_uid: str, tray_uuid: str | None, sak: int | None = None):
  559. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  560. mock_ws.broadcast = AsyncMock()
  561. resp = await async_client.post(
  562. f"{API}/nfc/tag-scanned",
  563. json={"device_id": "sb-1", "tag_uid": tag_uid, "tray_uuid": tray_uuid, "sak": sak},
  564. )
  565. assert resp.status_code == 200
  566. return resp.json()
  567. async def _tray_uuid_of(self, db_session: AsyncSession, spool_id: int) -> str | None:
  568. from sqlalchemy import select
  569. db_session.expire_all()
  570. return (await db_session.execute(select(Spool.tray_uuid).where(Spool.id == spool_id))).scalar_one()
  571. @pytest.mark.asyncio
  572. @pytest.mark.integration
  573. async def test_exact_tag_match_saves_tray_uuid(self, async_client: AsyncClient, spool_factory, db_session):
  574. spool_id = (await spool_factory(tag_uid="AABB1122")).id
  575. data = await self._scan(async_client, "AABB1122", self.TRAY)
  576. assert data["spool_id"] == spool_id
  577. assert await self._tray_uuid_of(db_session, spool_id) == self.TRAY
  578. # The spool's other tag now finds it.
  579. assert (await self._scan(async_client, "8E3A00A2", self.TRAY))["spool_id"] == spool_id
  580. @pytest.mark.asyncio
  581. @pytest.mark.integration
  582. async def test_fuzzy_tag_match_saves_nothing(self, async_client: AsyncClient, spool_factory, db_session):
  583. """A first-byte-variance match may be another spool's tag."""
  584. spool = await spool_factory(tag_uid="BABB1122")
  585. data = await self._scan(async_client, "AABB1122", self.TRAY)
  586. assert data["spool_id"] == spool.id
  587. assert await self._tray_uuid_of(db_session, spool.id) is None
  588. @pytest.mark.asyncio
  589. @pytest.mark.integration
  590. async def test_tray_uuid_held_by_another_spool_is_not_copied(
  591. self, async_client: AsyncClient, spool_factory, db_session
  592. ):
  593. from datetime import datetime, timezone
  594. await spool_factory(tag_uid="11111111", tray_uuid=self.TRAY, archived_at=datetime.now(timezone.utc))
  595. spool = await spool_factory(tag_uid="AABB1122")
  596. await self._scan(async_client, "AABB1122", self.TRAY)
  597. assert await self._tray_uuid_of(db_session, spool.id) is None
  598. @pytest.mark.asyncio
  599. @pytest.mark.integration
  600. async def test_existing_tray_uuid_is_kept(self, async_client: AsyncClient, spool_factory, db_session):
  601. other = "5E5498918CBF4B94A25EF669C24DECC3"
  602. spool = await spool_factory(tag_uid="AABB1122", tray_uuid=other)
  603. await self._scan(async_client, "AABB1122", self.TRAY)
  604. assert await self._tray_uuid_of(db_session, spool.id) == other
  605. @pytest.mark.asyncio
  606. @pytest.mark.integration
  607. async def test_full_uid_scan_saves_full_uid_over_cascade_level_1_link(
  608. self, async_client: AsyncClient, spool_factory, db_session
  609. ):
  610. """A spool linked from a level-1 read (#3258) gets the full UID on its first full scan."""
  611. from sqlalchemy import select
  612. spool_id = (await spool_factory(tag_uid="8804ECE6")).id
  613. data = await self._scan(async_client, "04ECE673D32A81", None)
  614. assert data["spool_id"] == spool_id
  615. db_session.expire_all()
  616. stored = (await db_session.execute(select(Spool.tag_uid).where(Spool.id == spool_id))).scalar_one()
  617. assert stored == "04ECE673D32A81"
  618. # A daemon not updated yet still finds it.
  619. assert (await self._scan(async_client, "8804ECE6", None))["spool_id"] == spool_id
  620. @pytest.mark.asyncio
  621. @pytest.mark.integration
  622. async def test_complete_uid_beginning_with_88_is_no_cascade_level_1_read(
  623. self, async_client: AsyncClient, spool_factory
  624. ):
  625. """An old daemon's level-1 read comes with SAK 0x04; a 4-byte clone card's 88 UID with 0x08 (#3258)."""
  626. spool_id = (await spool_factory(tag_uid="04ECE673D32A81")).id
  627. assert (await self._scan(async_client, "8804ECE6", None, sak=0x04))["spool_id"] == spool_id
  628. assert (await self._scan(async_client, "8804ECE6", None, sak=0x08))["matched"] is False
  629. @pytest.mark.asyncio
  630. @pytest.mark.integration
  631. async def test_spoolman_exact_tag_match_stores_tray_uuid_as_tag(self, async_client: AsyncClient):
  632. sm_spool = {
  633. "id": 7,
  634. "filament": {"material": "PLA", "name": "PLA Matte", "color_hex": "008080", "weight": 1000.0},
  635. "extra": {"tag": '"AABB1122"'},
  636. "used_weight": 0.0,
  637. }
  638. mock_client = MagicMock()
  639. mock_client.has_tag_api = AsyncMock(return_value=False)
  640. mock_client.add_native_tags = AsyncMock(return_value=0)
  641. mock_client.unlink_all_native_tags = AsyncMock()
  642. mock_client.get_spools = AsyncMock(return_value=[sm_spool])
  643. mock_client.find_spool_by_tag = AsyncMock(
  644. side_effect=lambda tag, cached_spools=None: sm_spool if tag == "AABB1122" else None
  645. )
  646. mock_client.merge_spool_extra = AsyncMock(return_value={})
  647. with patch(
  648. "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
  649. new_callable=AsyncMock,
  650. return_value=mock_client,
  651. ):
  652. data = await self._scan(async_client, "AABB1122", self.TRAY)
  653. assert data["spool_id"] == 7
  654. mock_client.merge_spool_extra.assert_awaited_once_with(7, {"tag": f'"{self.TRAY}"'})
  655. @pytest.mark.asyncio
  656. @pytest.mark.integration
  657. async def test_spoolman_match_by_tray_uuid_writes_nothing(self, async_client: AsyncClient):
  658. sm_spool = {
  659. "id": 7,
  660. "filament": {"material": "PLA", "name": "PLA Matte", "color_hex": "008080", "weight": 1000.0},
  661. "extra": {"tag": f'"{self.TRAY}"'},
  662. "used_weight": 0.0,
  663. }
  664. mock_client = MagicMock()
  665. mock_client.has_tag_api = AsyncMock(return_value=False)
  666. mock_client.add_native_tags = AsyncMock(return_value=0)
  667. mock_client.unlink_all_native_tags = AsyncMock()
  668. mock_client.get_spools = AsyncMock(return_value=[sm_spool])
  669. mock_client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
  670. mock_client.merge_spool_extra = AsyncMock(return_value={})
  671. with patch(
  672. "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
  673. new_callable=AsyncMock,
  674. return_value=mock_client,
  675. ):
  676. data = await self._scan(async_client, "AABB1122", self.TRAY)
  677. assert data["spool_id"] == 7
  678. mock_client.merge_spool_extra.assert_not_awaited()
  679. @pytest.mark.asyncio
  680. @pytest.mark.integration
  681. async def test_spoolman_write_failure_still_reports_the_match(self, async_client: AsyncClient):
  682. sm_spool = {
  683. "id": 7,
  684. "filament": {"material": "PLA", "name": "PLA Matte", "color_hex": "008080", "weight": 1000.0},
  685. "extra": {"tag": '"AABB1122"'},
  686. "used_weight": 0.0,
  687. }
  688. mock_client = MagicMock()
  689. mock_client.has_tag_api = AsyncMock(return_value=False)
  690. mock_client.add_native_tags = AsyncMock(return_value=0)
  691. mock_client.unlink_all_native_tags = AsyncMock()
  692. mock_client.get_spools = AsyncMock(return_value=[sm_spool])
  693. mock_client.find_spool_by_tag = AsyncMock(
  694. side_effect=lambda tag, cached_spools=None: sm_spool if tag == "AABB1122" else None
  695. )
  696. mock_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanUnavailableError("down"))
  697. with patch(
  698. "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
  699. new_callable=AsyncMock,
  700. return_value=mock_client,
  701. ):
  702. data = await self._scan(async_client, "AABB1122", self.TRAY)
  703. assert data["matched"] is True
  704. assert data["spool_id"] == 7
  705. @pytest.mark.asyncio
  706. @pytest.mark.integration
  707. async def test_tag_removed(self, async_client: AsyncClient):
  708. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  709. mock_ws.broadcast = AsyncMock()
  710. resp = await async_client.post(
  711. f"{API}/nfc/tag-removed",
  712. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  713. )
  714. assert resp.status_code == 200
  715. msg = mock_ws.broadcast.call_args[0][0]
  716. assert msg["type"] == "spoolbuddy_tag_removed"
  717. assert msg["device_id"] == "sb-1"
  718. assert msg["tag_uid"] == "AABB1122"
  719. # ============================================================================
  720. # NFC write-tag endpoints
  721. # ============================================================================
  722. class TestWriteTagEndpoints:
  723. @pytest.mark.asyncio
  724. @pytest.mark.integration
  725. async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):
  726. device = await device_factory(device_id="sb-wt")
  727. spool = await spool_factory(material="PLA", brand="Polymaker", color_name="Red", rgba="FF0000FF")
  728. resp = await async_client.post(
  729. f"{API}/nfc/write-tag",
  730. json={"device_id": device.device_id, "spool_id": spool.id},
  731. )
  732. assert resp.status_code == 200
  733. assert resp.json()["status"] == "queued"
  734. # Verify heartbeat returns write_tag command with payload
  735. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  736. mock_ws.broadcast = AsyncMock()
  737. hb = await async_client.post(
  738. f"{API}/devices/{device.device_id}/heartbeat",
  739. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  740. )
  741. hb_data = hb.json()
  742. assert hb_data["pending_command"] == "write_tag"
  743. assert hb_data["pending_write_payload"] is not None
  744. assert hb_data["pending_write_payload"]["spool_id"] == spool.id
  745. assert "ndef_data_hex" in hb_data["pending_write_payload"]
  746. @pytest.mark.asyncio
  747. @pytest.mark.integration
  748. async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):
  749. """write_tag command persists across heartbeats until write-result clears it."""
  750. device = await device_factory(device_id="sb-wt-persist")
  751. spool = await spool_factory(material="PETG")
  752. await async_client.post(
  753. f"{API}/nfc/write-tag",
  754. json={"device_id": device.device_id, "spool_id": spool.id},
  755. )
  756. # First heartbeat — command present
  757. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  758. mock_ws.broadcast = AsyncMock()
  759. hb1 = await async_client.post(
  760. f"{API}/devices/{device.device_id}/heartbeat",
  761. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  762. )
  763. assert hb1.json()["pending_command"] == "write_tag"
  764. # Second heartbeat — should still be present (not cleared like tare)
  765. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  766. mock_ws.broadcast = AsyncMock()
  767. hb2 = await async_client.post(
  768. f"{API}/devices/{device.device_id}/heartbeat",
  769. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
  770. )
  771. assert hb2.json()["pending_command"] == "write_tag"
  772. @pytest.mark.asyncio
  773. @pytest.mark.integration
  774. async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):
  775. device = await device_factory(device_id="sb-wt-nospool")
  776. resp = await async_client.post(
  777. f"{API}/nfc/write-tag",
  778. json={"device_id": device.device_id, "spool_id": 99999},
  779. )
  780. assert resp.status_code == 404
  781. @pytest.mark.asyncio
  782. @pytest.mark.integration
  783. async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):
  784. spool = await spool_factory()
  785. resp = await async_client.post(
  786. f"{API}/nfc/write-tag",
  787. json={"device_id": "nonexistent", "spool_id": spool.id},
  788. )
  789. assert resp.status_code == 404
  790. @pytest.mark.asyncio
  791. @pytest.mark.integration
  792. async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):
  793. device = await device_factory(device_id="sb-wr", pending_command="write_tag")
  794. spool = await spool_factory(material="PLA", tag_uid=None)
  795. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  796. mock_ws.broadcast = AsyncMock()
  797. resp = await async_client.post(
  798. f"{API}/nfc/write-result",
  799. json={
  800. "device_id": device.device_id,
  801. "spool_id": spool.id,
  802. "tag_uid": "04AABB11223344",
  803. "success": True,
  804. },
  805. )
  806. assert resp.status_code == 200
  807. msg = mock_ws.broadcast.call_args[0][0]
  808. assert msg["type"] == "spoolbuddy_tag_written"
  809. assert msg["spool_id"] == spool.id
  810. assert msg["tag_uid"] == "04AABB11223344"
  811. # Verify spool got tag linked
  812. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  813. spool_data = spool_resp.json()
  814. assert spool_data["tag_uid"] == "04AABB11223344"
  815. assert spool_data["tag_type"] == "ntag"
  816. assert spool_data["data_origin"] == "opentag3d"
  817. assert spool_data["encode_time"] is not None
  818. @pytest.mark.asyncio
  819. @pytest.mark.integration
  820. async def test_write_result_failure_broadcasts_error(
  821. self, async_client: AsyncClient, device_factory, spool_factory
  822. ):
  823. device = await device_factory(device_id="sb-wr-fail", pending_command="write_tag")
  824. spool = await spool_factory(material="PLA", tag_uid=None)
  825. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  826. mock_ws.broadcast = AsyncMock()
  827. resp = await async_client.post(
  828. f"{API}/nfc/write-result",
  829. json={
  830. "device_id": device.device_id,
  831. "spool_id": spool.id,
  832. "tag_uid": "04AABBCC",
  833. "success": False,
  834. "message": "Write or verification failed",
  835. },
  836. )
  837. assert resp.status_code == 200
  838. msg = mock_ws.broadcast.call_args[0][0]
  839. assert msg["type"] == "spoolbuddy_tag_write_failed"
  840. assert msg["message"] == "Write or verification failed"
  841. # Verify spool NOT linked
  842. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  843. assert spool_resp.json()["tag_uid"] is None
  844. @pytest.mark.asyncio
  845. @pytest.mark.integration
  846. async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):
  847. device = await device_factory(
  848. device_id="sb-wr-clear",
  849. pending_command="write_tag",
  850. pending_write_payload='{"spool_id": 1, "ndef_data_hex": "E110120003"}',
  851. )
  852. spool = await spool_factory()
  853. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  854. mock_ws.broadcast = AsyncMock()
  855. await async_client.post(
  856. f"{API}/nfc/write-result",
  857. json={
  858. "device_id": device.device_id,
  859. "spool_id": spool.id,
  860. "tag_uid": "AABBCCDD",
  861. "success": True,
  862. },
  863. )
  864. # Heartbeat should have no pending command
  865. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  866. mock_ws.broadcast = AsyncMock()
  867. hb = await async_client.post(
  868. f"{API}/devices/{device.device_id}/heartbeat",
  869. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 30},
  870. )
  871. assert hb.json()["pending_command"] is None
  872. assert hb.json()["pending_write_payload"] is None
  873. @pytest.mark.asyncio
  874. @pytest.mark.integration
  875. async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):
  876. device = await device_factory(device_id="sb-cancel")
  877. spool = await spool_factory()
  878. # Queue a write
  879. await async_client.post(
  880. f"{API}/nfc/write-tag",
  881. json={"device_id": device.device_id, "spool_id": spool.id},
  882. )
  883. # Cancel it
  884. resp = await async_client.post(f"{API}/devices/{device.device_id}/cancel-write", json={})
  885. assert resp.status_code == 200
  886. # Heartbeat should have no pending command
  887. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  888. mock_ws.broadcast = AsyncMock()
  889. hb = await async_client.post(
  890. f"{API}/devices/{device.device_id}/heartbeat",
  891. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  892. )
  893. assert hb.json()["pending_command"] is None
  894. @pytest.mark.asyncio
  895. @pytest.mark.integration
  896. async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):
  897. resp = await async_client.post(f"{API}/devices/ghost/cancel-write", json={})
  898. assert resp.status_code == 404
  899. @pytest.mark.asyncio
  900. @pytest.mark.integration
  901. async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):
  902. """Verify the NDEF data in the heartbeat is a valid OpenTag3D message."""
  903. device = await device_factory(device_id="sb-wt-ndef")
  904. spool = await spool_factory(
  905. material="PLA",
  906. brand="Polymaker",
  907. color_name="White",
  908. rgba="FFFFFFFF",
  909. label_weight=1000,
  910. )
  911. await async_client.post(
  912. f"{API}/nfc/write-tag",
  913. json={"device_id": device.device_id, "spool_id": spool.id},
  914. )
  915. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  916. mock_ws.broadcast = AsyncMock()
  917. hb = await async_client.post(
  918. f"{API}/devices/{device.device_id}/heartbeat",
  919. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  920. )
  921. payload = hb.json()["pending_write_payload"]
  922. ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
  923. # The data area starts with the NDEF TLV, no CC copy (#3500)
  924. assert ndef_bytes[0] == 0x03
  925. # NDEF record: TNF=MIME, type=application/opentag3d
  926. assert ndef_bytes[2] == 0xD2
  927. assert ndef_bytes[5:26] == b"application/opentag3d"
  928. # Terminator
  929. assert ndef_bytes[-1] == 0xFE
  930. # Total size fits NTAG213
  931. assert len(ndef_bytes) <= 144
  932. # OpenTag3D 2.004 beside it, for a tag with room for it (#3500)
  933. v2 = bytes.fromhex(payload["ndef_v2_hex"])
  934. assert v2[5:26] == b"application/opentag3d"
  935. assert int.from_bytes(v2[26:28], "big") == 2004
  936. assert v2[26 + 0x0C : 26 + 0x15] == b"Polymaker"
  937. # ============================================================================
  938. # Scale endpoints
  939. # ============================================================================
  940. class TestScaleEndpoints:
  941. @pytest.mark.asyncio
  942. @pytest.mark.integration
  943. async def test_scale_reading_broadcast(self, async_client: AsyncClient):
  944. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  945. mock_ws.broadcast = AsyncMock()
  946. resp = await async_client.post(
  947. f"{API}/scale/reading",
  948. json={
  949. "device_id": "sb-1",
  950. "weight_grams": 823.5,
  951. "stable": True,
  952. "raw_adc": 456789,
  953. },
  954. )
  955. assert resp.status_code == 200
  956. msg = mock_ws.broadcast.call_args[0][0]
  957. assert msg["type"] == "spoolbuddy_weight"
  958. assert msg["device_id"] == "sb-1"
  959. assert msg["weight_grams"] == 823.5
  960. assert msg["stable"] is True
  961. assert msg["raw_adc"] == 456789
  962. @pytest.mark.asyncio
  963. @pytest.mark.integration
  964. async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
  965. # label=1000g, core=250g, scale reads 750g
  966. # net_filament = max(0, 750 - 250) = 500
  967. # weight_used = max(0, 1000 - 500) = 500
  968. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  969. resp = await async_client.post(
  970. f"{API}/scale/update-spool-weight",
  971. json={"spool_id": spool.id, "weight_grams": 750},
  972. )
  973. assert resp.status_code == 200
  974. data = resp.json()
  975. assert data["weight_used"] == 500
  976. @pytest.mark.asyncio
  977. @pytest.mark.integration
  978. async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
  979. # label=1000g, core=250g, scale reads 1250g (full spool)
  980. # net_filament = max(0, 1250 - 250) = 1000
  981. # weight_used = max(0, 1000 - 1000) = 0
  982. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
  983. resp = await async_client.post(
  984. f"{API}/scale/update-spool-weight",
  985. json={"spool_id": spool.id, "weight_grams": 1250},
  986. )
  987. assert resp.status_code == 200
  988. data = resp.json()
  989. assert data["weight_used"] == 0
  990. @pytest.mark.asyncio
  991. @pytest.mark.integration
  992. async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
  993. """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
  994. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  995. resp = await async_client.post(
  996. f"{API}/scale/update-spool-weight",
  997. json={"spool_id": spool.id, "weight_grams": 750},
  998. )
  999. assert resp.status_code == 200
  1000. # Fetch the spool via inventory API to verify stored fields
  1001. spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
  1002. assert spool_resp.status_code == 200
  1003. spool_data = spool_resp.json()
  1004. assert spool_data["last_scale_weight"] == 750
  1005. assert spool_data["last_weighed_at"] is not None
  1006. @pytest.mark.asyncio
  1007. @pytest.mark.integration
  1008. async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
  1009. resp = await async_client.post(
  1010. f"{API}/scale/update-spool-weight",
  1011. json={"spool_id": 99999, "weight_grams": 500},
  1012. )
  1013. assert resp.status_code == 404
  1014. # ============================================================================
  1015. # Calibration endpoints
  1016. # ============================================================================
  1017. class TestCalibrationEndpoints:
  1018. @pytest.mark.asyncio
  1019. @pytest.mark.integration
  1020. async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
  1021. await device_factory(device_id="sb-tare")
  1022. resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
  1023. assert resp.status_code == 200
  1024. assert resp.json()["status"] == "ok"
  1025. # Verify pending_command via heartbeat
  1026. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1027. mock_ws.broadcast = AsyncMock()
  1028. hb = await async_client.post(
  1029. f"{API}/devices/sb-tare/heartbeat",
  1030. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
  1031. )
  1032. assert hb.json()["pending_command"] == "tare"
  1033. @pytest.mark.asyncio
  1034. @pytest.mark.integration
  1035. async def test_tare_unknown_device_404(self, async_client: AsyncClient):
  1036. resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
  1037. assert resp.status_code == 404
  1038. @pytest.mark.asyncio
  1039. @pytest.mark.integration
  1040. async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
  1041. await device_factory(device_id="sb-st", calibration_factor=0.005)
  1042. resp = await async_client.post(
  1043. f"{API}/devices/sb-st/calibration/set-tare",
  1044. json={"tare_offset": 54321},
  1045. )
  1046. assert resp.status_code == 200
  1047. data = resp.json()
  1048. assert data["tare_offset"] == 54321
  1049. assert data["calibration_factor"] == pytest.approx(0.005)
  1050. @pytest.mark.asyncio
  1051. @pytest.mark.integration
  1052. async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
  1053. # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
  1054. await device_factory(device_id="sb-cf", tare_offset=10000)
  1055. resp = await async_client.post(
  1056. f"{API}/devices/sb-cf/calibration/set-factor",
  1057. json={"known_weight_grams": 200, "raw_adc": 50000},
  1058. )
  1059. assert resp.status_code == 200
  1060. data = resp.json()
  1061. assert data["calibration_factor"] == pytest.approx(0.005)
  1062. assert data["tare_offset"] == 10000
  1063. @pytest.mark.asyncio
  1064. @pytest.mark.integration
  1065. async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
  1066. # raw_adc == tare_offset → delta is 0 → 400 error
  1067. await device_factory(device_id="sb-zero", tare_offset=5000)
  1068. resp = await async_client.post(
  1069. f"{API}/devices/sb-zero/calibration/set-factor",
  1070. json={"known_weight_grams": 100, "raw_adc": 5000},
  1071. )
  1072. assert resp.status_code == 400
  1073. @pytest.mark.asyncio
  1074. @pytest.mark.integration
  1075. async def test_get_calibration(self, async_client: AsyncClient, device_factory):
  1076. await device_factory(
  1077. device_id="sb-gcal",
  1078. tare_offset=11111,
  1079. calibration_factor=0.0042,
  1080. )
  1081. resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
  1082. assert resp.status_code == 200
  1083. data = resp.json()
  1084. assert data["tare_offset"] == 11111
  1085. assert data["calibration_factor"] == pytest.approx(0.0042)
  1086. # ============================================================================
  1087. # Display endpoints
  1088. # ============================================================================
  1089. class TestDisplayEndpoints:
  1090. @pytest.mark.asyncio
  1091. @pytest.mark.integration
  1092. async def test_update_display_settings(self, async_client: AsyncClient, device_factory):
  1093. await device_factory(device_id="sb-disp", display_brightness=100, display_blank_timeout=0)
  1094. resp = await async_client.put(
  1095. f"{API}/devices/sb-disp/display",
  1096. json={"brightness": 75, "blank_timeout": 300},
  1097. )
  1098. assert resp.status_code == 200
  1099. data = resp.json()
  1100. assert data["brightness"] == 75
  1101. assert data["blank_timeout"] == 300
  1102. @pytest.mark.asyncio
  1103. @pytest.mark.integration
  1104. async def test_update_display_persists_via_heartbeat(self, async_client: AsyncClient, device_factory):
  1105. await device_factory(device_id="sb-disp-hb")
  1106. await async_client.put(
  1107. f"{API}/devices/sb-disp-hb/display",
  1108. json={"brightness": 50, "blank_timeout": 600},
  1109. )
  1110. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1111. mock_ws.broadcast = AsyncMock()
  1112. hb = await async_client.post(
  1113. f"{API}/devices/sb-disp-hb/heartbeat",
  1114. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
  1115. )
  1116. assert hb.json()["display_brightness"] == 50
  1117. assert hb.json()["display_blank_timeout"] == 600
  1118. @pytest.mark.asyncio
  1119. @pytest.mark.integration
  1120. async def test_update_display_unknown_device_404(self, async_client: AsyncClient):
  1121. resp = await async_client.put(
  1122. f"{API}/devices/ghost/display",
  1123. json={"brightness": 50, "blank_timeout": 60},
  1124. )
  1125. assert resp.status_code == 404
  1126. @pytest.mark.asyncio
  1127. @pytest.mark.integration
  1128. async def test_update_display_validates_brightness(self, async_client: AsyncClient, device_factory):
  1129. await device_factory(device_id="sb-disp-val")
  1130. resp = await async_client.put(
  1131. f"{API}/devices/sb-disp-val/display",
  1132. json={"brightness": 150, "blank_timeout": 0},
  1133. )
  1134. assert resp.status_code == 422 # Validation error: brightness > 100
  1135. @pytest.mark.asyncio
  1136. @pytest.mark.integration
  1137. async def test_get_display_settings(self, async_client: AsyncClient, device_factory):
  1138. """The kiosk idle watchdog (install/spoolbuddy-idle.sh) reads this
  1139. endpoint on autostart to configure swayidle with the user-selected
  1140. blank timeout before launching. See issue #937."""
  1141. await device_factory(device_id="sb-disp-get", display_brightness=60, display_blank_timeout=450)
  1142. resp = await async_client.get(f"{API}/devices/sb-disp-get/display")
  1143. assert resp.status_code == 200
  1144. data = resp.json()
  1145. assert data["brightness"] == 60
  1146. assert data["blank_timeout"] == 450
  1147. @pytest.mark.asyncio
  1148. @pytest.mark.integration
  1149. async def test_get_display_unknown_device_404(self, async_client: AsyncClient):
  1150. resp = await async_client.get(f"{API}/devices/ghost/display")
  1151. assert resp.status_code == 404
  1152. # ============================================================================
  1153. # Update endpoints
  1154. # ============================================================================
  1155. class TestUpdateEndpoints:
  1156. @pytest.mark.asyncio
  1157. @pytest.mark.integration
  1158. async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):
  1159. await device_factory(device_id="sb-upd")
  1160. with (
  1161. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1162. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  1163. ):
  1164. mock_ws.broadcast = AsyncMock()
  1165. resp = await async_client.post(f"{API}/devices/sb-upd/update")
  1166. assert resp.status_code == 200
  1167. assert resp.json()["status"] == "ok"
  1168. @pytest.mark.asyncio
  1169. @pytest.mark.integration
  1170. async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
  1171. await device_factory(
  1172. device_id="sb-upd-off",
  1173. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  1174. )
  1175. resp = await async_client.post(f"{API}/devices/sb-upd-off/update")
  1176. assert resp.status_code == 409
  1177. @pytest.mark.asyncio
  1178. @pytest.mark.integration
  1179. async def test_trigger_update_unknown_device_404(self, async_client: AsyncClient):
  1180. resp = await async_client.post(f"{API}/devices/ghost/update")
  1181. assert resp.status_code == 404
  1182. @pytest.mark.asyncio
  1183. @pytest.mark.integration
  1184. async def test_trigger_update_already_updating(self, async_client: AsyncClient, device_factory):
  1185. await device_factory(device_id="sb-upd-dup", update_status="updating")
  1186. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1187. mock_ws.broadcast = AsyncMock()
  1188. resp = await async_client.post(f"{API}/devices/sb-upd-dup/update")
  1189. assert resp.status_code == 200
  1190. assert resp.json()["status"] == "already_updating"
  1191. @pytest.mark.asyncio
  1192. @pytest.mark.integration
  1193. async def test_ssh_public_key_error_does_not_leak_exception_text(self, async_client: AsyncClient):
  1194. """SSH public-key 500 must not expose raw exception details (M3)."""
  1195. from backend.app.services.spoolbuddy_ssh import get_public_key
  1196. with patch(
  1197. "backend.app.services.spoolbuddy_ssh.get_public_key",
  1198. AsyncMock(side_effect=RuntimeError("REDACT_ME internal path /data/keys/id_ed25519")),
  1199. ):
  1200. resp = await async_client.get(f"{API}/ssh/public-key")
  1201. assert resp.status_code == 500
  1202. body = resp.json()["detail"]
  1203. assert "REDACT_ME" not in body
  1204. assert "/data/keys" not in body
  1205. assert "id_ed25519" not in body
  1206. @pytest.mark.asyncio
  1207. @pytest.mark.integration
  1208. async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
  1209. await device_factory(device_id="sb-upd-resp", update_status="complete", update_message="Done!")
  1210. resp = await async_client.get(f"{API}/devices")
  1211. assert resp.status_code == 200
  1212. device = next(d for d in resp.json() if d["device_id"] == "sb-upd-resp")
  1213. assert device["update_status"] == "complete"
  1214. assert device["update_message"] == "Done!"
  1215. @pytest.mark.asyncio
  1216. @pytest.mark.integration
  1217. async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
  1218. """GET /devices/{id}/update-check compares device version against APP_VERSION."""
  1219. await device_factory(device_id="sb-uc", firmware_version="0.1.0")
  1220. resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
  1221. assert resp.status_code == 200
  1222. data = resp.json()
  1223. assert data["current_version"] == "0.1.0"
  1224. assert data["latest_version"] is not None
  1225. assert data["update_available"] is True
  1226. @pytest.mark.asyncio
  1227. @pytest.mark.integration
  1228. async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
  1229. from backend.app.core.config import APP_VERSION
  1230. await device_factory(device_id="sb-uc2", firmware_version=APP_VERSION)
  1231. resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
  1232. assert resp.status_code == 200
  1233. assert resp.json()["update_available"] is False
  1234. @pytest.mark.asyncio
  1235. @pytest.mark.integration
  1236. async def test_update_check_unknown_device_404(self, async_client: AsyncClient):
  1237. resp = await async_client.get(f"{API}/devices/ghost/update-check")
  1238. assert resp.status_code == 404
  1239. @pytest.mark.asyncio
  1240. @pytest.mark.integration
  1241. async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
  1242. await device_factory(device_id="sb-upd-ws")
  1243. with (
  1244. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1245. patch("backend.app.services.spoolbuddy_ssh.perform_ssh_update", new_callable=AsyncMock),
  1246. ):
  1247. mock_ws.broadcast = AsyncMock()
  1248. await async_client.post(f"{API}/devices/sb-upd-ws/update")
  1249. mock_ws.broadcast.assert_called_once()
  1250. msg = mock_ws.broadcast.call_args[0][0]
  1251. assert msg["type"] == "spoolbuddy_update"
  1252. assert msg["device_id"] == "sb-upd-ws"
  1253. assert msg["update_status"] == "pending"
  1254. # ============================================================================
  1255. # System command endpoints
  1256. # ============================================================================
  1257. class TestSystemCommandEndpoints:
  1258. @pytest.mark.asyncio
  1259. @pytest.mark.integration
  1260. async def test_queue_reboot(self, async_client: AsyncClient, device_factory):
  1261. await device_factory(device_id="sb-reboot")
  1262. resp = await async_client.post(
  1263. f"{API}/devices/sb-reboot/system/command",
  1264. json={"command": "reboot"},
  1265. )
  1266. assert resp.status_code == 200
  1267. data = resp.json()
  1268. assert data["status"] == "queued"
  1269. assert data["command"] == "reboot"
  1270. @pytest.mark.asyncio
  1271. @pytest.mark.integration
  1272. async def test_queue_shutdown(self, async_client: AsyncClient, device_factory):
  1273. await device_factory(device_id="sb-shutdown")
  1274. resp = await async_client.post(
  1275. f"{API}/devices/sb-shutdown/system/command",
  1276. json={"command": "shutdown"},
  1277. )
  1278. assert resp.status_code == 200
  1279. assert resp.json()["command"] == "shutdown"
  1280. @pytest.mark.asyncio
  1281. @pytest.mark.integration
  1282. async def test_queue_restart_daemon(self, async_client: AsyncClient, device_factory):
  1283. await device_factory(device_id="sb-rd")
  1284. resp = await async_client.post(
  1285. f"{API}/devices/sb-rd/system/command",
  1286. json={"command": "restart_daemon"},
  1287. )
  1288. assert resp.status_code == 200
  1289. assert resp.json()["command"] == "restart_daemon"
  1290. @pytest.mark.asyncio
  1291. @pytest.mark.integration
  1292. async def test_queue_restart_browser(self, async_client: AsyncClient, device_factory):
  1293. await device_factory(device_id="sb-rb")
  1294. resp = await async_client.post(
  1295. f"{API}/devices/sb-rb/system/command",
  1296. json={"command": "restart_browser"},
  1297. )
  1298. assert resp.status_code == 200
  1299. assert resp.json()["command"] == "restart_browser"
  1300. @pytest.mark.asyncio
  1301. @pytest.mark.integration
  1302. async def test_invalid_command_rejected(self, async_client: AsyncClient, device_factory):
  1303. await device_factory(device_id="sb-invalid")
  1304. resp = await async_client.post(
  1305. f"{API}/devices/sb-invalid/system/command",
  1306. json={"command": "format_disk"},
  1307. )
  1308. assert resp.status_code == 400
  1309. assert "Invalid command" in resp.json()["detail"]
  1310. @pytest.mark.asyncio
  1311. @pytest.mark.integration
  1312. async def test_command_unknown_device_404(self, async_client: AsyncClient):
  1313. resp = await async_client.post(
  1314. f"{API}/devices/ghost/system/command",
  1315. json={"command": "reboot"},
  1316. )
  1317. assert resp.status_code == 404
  1318. @pytest.mark.asyncio
  1319. @pytest.mark.integration
  1320. async def test_command_offline_device_409(self, async_client: AsyncClient, device_factory):
  1321. await device_factory(
  1322. device_id="sb-offline-cmd",
  1323. last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
  1324. )
  1325. resp = await async_client.post(
  1326. f"{API}/devices/sb-offline-cmd/system/command",
  1327. json={"command": "reboot"},
  1328. )
  1329. assert resp.status_code == 409
  1330. assert "offline" in resp.json()["detail"].lower()
  1331. @pytest.mark.asyncio
  1332. @pytest.mark.integration
  1333. async def test_command_sets_pending_command(self, async_client: AsyncClient, device_factory, db_session):
  1334. device = await device_factory(device_id="sb-pending")
  1335. await async_client.post(
  1336. f"{API}/devices/sb-pending/system/command",
  1337. json={"command": "restart_daemon"},
  1338. )
  1339. await db_session.refresh(device)
  1340. assert device.pending_command == "restart_daemon"
  1341. @pytest.mark.asyncio
  1342. @pytest.mark.integration
  1343. async def test_heartbeat_clears_system_command(self, async_client: AsyncClient, device_factory):
  1344. """System commands (reboot/shutdown/restart_*) are fire-and-forget — heartbeat clears them."""
  1345. await device_factory(device_id="sb-hb-clear")
  1346. # Queue a command
  1347. await async_client.post(
  1348. f"{API}/devices/sb-hb-clear/system/command",
  1349. json={"command": "restart_browser"},
  1350. )
  1351. # Heartbeat should return the command and clear it
  1352. resp = await async_client.post(
  1353. f"{API}/devices/sb-hb-clear/heartbeat",
  1354. json={"nfc_ok": True, "scale_ok": True, "uptime_s": 100},
  1355. )
  1356. assert resp.status_code == 200
  1357. data = resp.json()
  1358. assert data["pending_command"] == "restart_browser"
  1359. # ============================================================================
  1360. # Spoolman-aware SpoolBuddy endpoints
  1361. # ============================================================================
  1362. @pytest.fixture
  1363. async def spoolman_settings(db_session: AsyncSession):
  1364. """Create Spoolman settings in the database (enabled with URL)."""
  1365. from backend.app.models.settings import Settings
  1366. settings = [
  1367. Settings(key="spoolman_enabled", value="true"),
  1368. Settings(key="spoolman_url", value="http://spoolman.local:7912"),
  1369. ]
  1370. for s in settings:
  1371. db_session.add(s)
  1372. await db_session.commit()
  1373. return settings
  1374. def _mock_spoolman_client(base_url: str = "http://spoolman.local:7912") -> MagicMock:
  1375. client = MagicMock()
  1376. client.has_tag_api = AsyncMock(return_value=False)
  1377. client.add_native_tags = AsyncMock(return_value=0)
  1378. client.unlink_all_native_tags = AsyncMock()
  1379. client.base_url = base_url
  1380. client.get_spools = AsyncMock(return_value=[])
  1381. client.get_spool = AsyncMock(return_value={})
  1382. client.find_spool_by_tag = AsyncMock(return_value=None)
  1383. client.update_spool = AsyncMock(return_value=None)
  1384. client.merge_spool_extra = AsyncMock(return_value={"id": 0})
  1385. return client
  1386. def _spoolman_spool_fixture(
  1387. spool_id: int,
  1388. spool_weight: float = 196.0,
  1389. filament_weight: float = 1000.0,
  1390. spool_level_spool_weight=None,
  1391. ) -> dict:
  1392. """Build a minimal Spoolman spool dict with realistic core weight from filament.spool_weight."""
  1393. raw = {
  1394. "id": spool_id,
  1395. "filament": {"weight": filament_weight, "spool_weight": spool_weight},
  1396. "used_weight": 0.0,
  1397. }
  1398. if spool_level_spool_weight is not None:
  1399. raw["spool_weight"] = spool_level_spool_weight
  1400. return raw
  1401. class TestUpdateSpoolWeightSpoolman:
  1402. """update-spool-weight routes to Spoolman when Spoolman mode is active."""
  1403. @pytest.mark.asyncio
  1404. @pytest.mark.integration
  1405. async def test_spoolman_mode_uses_filament_spool_weight(self, async_client: AsyncClient, spoolman_settings):
  1406. """core_weight comes from filament.spool_weight, not a hardcoded constant."""
  1407. sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0)
  1408. mock_client = _mock_spoolman_client()
  1409. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1410. mock_client.update_spool = AsyncMock(return_value=sm_spool)
  1411. with (
  1412. patch(
  1413. "backend.app.services.spoolman.get_spoolman_client",
  1414. AsyncMock(return_value=mock_client),
  1415. ),
  1416. patch(
  1417. "backend.app.services.spoolman.init_spoolman_client",
  1418. AsyncMock(return_value=mock_client),
  1419. ),
  1420. ):
  1421. resp = await async_client.post(
  1422. f"{API}/scale/update-spool-weight",
  1423. json={"spool_id": 42, "weight_grams": 750},
  1424. )
  1425. assert resp.status_code == 200
  1426. data = resp.json()
  1427. assert data["status"] == "ok"
  1428. # remaining = max(0, 750 - 196) = 554 → weight_used = 1000 - 554 = 446
  1429. assert data["weight_used"] == pytest.approx(446.0)
  1430. mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(554.0))
  1431. @pytest.mark.asyncio
  1432. @pytest.mark.integration
  1433. async def test_spoolman_mode_announces_the_weight(self, async_client: AsyncClient, spoolman_settings):
  1434. """WU-9: other open Inventory tabs refresh only on inventory_changed."""
  1435. sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0)
  1436. mock_client = _mock_spoolman_client()
  1437. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1438. mock_client.update_spool = AsyncMock(return_value=sm_spool)
  1439. with (
  1440. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
  1441. patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
  1442. patch("backend.app.api.routes.spoolbuddy.ws_manager") as ws,
  1443. ):
  1444. ws.broadcast = AsyncMock()
  1445. await async_client.post(f"{API}/scale/update-spool-weight", json={"spool_id": 42, "weight_grams": 750})
  1446. assert {"type": "inventory_changed"} in [c.args[0] for c in ws.broadcast.call_args_list]
  1447. @pytest.mark.asyncio
  1448. @pytest.mark.integration
  1449. async def test_spoolman_mode_clamps_remaining_to_zero(self, async_client: AsyncClient, spoolman_settings):
  1450. """Scale weight below core weight → remaining_weight = 0."""
  1451. sm_spool = _spoolman_spool_fixture(7, spool_weight=196.0, filament_weight=1000.0)
  1452. mock_client = _mock_spoolman_client()
  1453. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1454. mock_client.update_spool = AsyncMock(return_value=sm_spool)
  1455. with (
  1456. patch(
  1457. "backend.app.services.spoolman.get_spoolman_client",
  1458. AsyncMock(return_value=mock_client),
  1459. ),
  1460. patch(
  1461. "backend.app.services.spoolman.init_spoolman_client",
  1462. AsyncMock(return_value=mock_client),
  1463. ),
  1464. ):
  1465. resp = await async_client.post(
  1466. f"{API}/scale/update-spool-weight",
  1467. json={"spool_id": 7, "weight_grams": 100},
  1468. )
  1469. assert resp.status_code == 200
  1470. mock_client.update_spool.assert_called_once_with(spool_id=7, remaining_weight=0.0)
  1471. @pytest.mark.asyncio
  1472. @pytest.mark.integration
  1473. async def test_spoolman_mode_404_when_spool_not_found(self, async_client: AsyncClient, spoolman_settings):
  1474. """404 when Spoolman doesn't know the spool."""
  1475. mock_client = _mock_spoolman_client()
  1476. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found"))
  1477. with (
  1478. patch(
  1479. "backend.app.services.spoolman.get_spoolman_client",
  1480. AsyncMock(return_value=mock_client),
  1481. ),
  1482. patch(
  1483. "backend.app.services.spoolman.init_spoolman_client",
  1484. AsyncMock(return_value=mock_client),
  1485. ),
  1486. ):
  1487. resp = await async_client.post(
  1488. f"{API}/scale/update-spool-weight",
  1489. json={"spool_id": 9999, "weight_grams": 500},
  1490. )
  1491. assert resp.status_code == 404
  1492. @pytest.mark.asyncio
  1493. @pytest.mark.integration
  1494. async def test_spoolman_mode_503_on_client_failure(self, async_client: AsyncClient, spoolman_settings):
  1495. """503 is returned when Spoolman is unreachable during weight update."""
  1496. sm_spool = _spoolman_spool_fixture(99)
  1497. mock_client = _mock_spoolman_client()
  1498. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1499. mock_client.update_spool = AsyncMock(side_effect=SpoolmanUnavailableError("Spoolman down"))
  1500. with (
  1501. patch(
  1502. "backend.app.services.spoolman.get_spoolman_client",
  1503. AsyncMock(return_value=mock_client),
  1504. ),
  1505. patch(
  1506. "backend.app.services.spoolman.init_spoolman_client",
  1507. AsyncMock(return_value=mock_client),
  1508. ),
  1509. ):
  1510. resp = await async_client.post(
  1511. f"{API}/scale/update-spool-weight",
  1512. json={"spool_id": 99, "weight_grams": 500},
  1513. )
  1514. assert resp.status_code == 503
  1515. @pytest.mark.asyncio
  1516. @pytest.mark.integration
  1517. async def test_local_mode_unchanged(self, async_client: AsyncClient, spool_factory):
  1518. """When Spoolman is NOT enabled, local DB update still works."""
  1519. spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  1520. resp = await async_client.post(
  1521. f"{API}/scale/update-spool-weight",
  1522. json={"spool_id": spool.id, "weight_grams": 750},
  1523. )
  1524. assert resp.status_code == 200
  1525. assert resp.json()["weight_used"] == 500
  1526. @pytest.mark.asyncio
  1527. @pytest.mark.integration
  1528. async def test_stale_local_row_does_not_shadow_spoolman(
  1529. self, async_client: AsyncClient, db_session, spool_factory, spoolman_settings
  1530. ):
  1531. """Regression for #1530: when Spoolman mode is on, a stale local Spool
  1532. sharing the same numeric id must NOT absorb the update — Spoolman is
  1533. the authoritative target."""
  1534. local_spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
  1535. # Spoolman spool with the SAME numeric id as the local stale row.
  1536. sm_spool = _spoolman_spool_fixture(local_spool.id, spool_weight=250.0, filament_weight=1000.0)
  1537. mock_client = _mock_spoolman_client()
  1538. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1539. mock_client.update_spool = AsyncMock(return_value=sm_spool)
  1540. with (
  1541. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
  1542. patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
  1543. ):
  1544. resp = await async_client.post(
  1545. f"{API}/scale/update-spool-weight",
  1546. json={"spool_id": local_spool.id, "weight_grams": 750},
  1547. )
  1548. assert resp.status_code == 200
  1549. # Spoolman got the update.
  1550. mock_client.update_spool.assert_called_once_with(spool_id=local_spool.id, remaining_weight=pytest.approx(500.0))
  1551. # Local row is untouched — the bug was that the local update silently
  1552. # absorbed the request while Spoolman stayed stale.
  1553. await db_session.refresh(local_spool)
  1554. assert local_spool.weight_used == 0
  1555. assert local_spool.last_scale_weight is None
  1556. @pytest.mark.asyncio
  1557. @pytest.mark.integration
  1558. async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
  1559. """spool.spool_weight overrides filament.spool_weight for tare calculation."""
  1560. sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0, spool_level_spool_weight=300)
  1561. mock_client = _mock_spoolman_client()
  1562. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1563. mock_client.update_spool = AsyncMock(return_value=sm_spool)
  1564. with (
  1565. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
  1566. patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
  1567. ):
  1568. resp = await async_client.post(
  1569. f"{API}/scale/update-spool-weight",
  1570. json={"spool_id": 42, "weight_grams": 750},
  1571. )
  1572. assert resp.status_code == 200
  1573. # remaining = 750 - 300 = 450; weight_used = 1000 - 450 = 550
  1574. assert resp.json()["weight_used"] == pytest.approx(550.0)
  1575. mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(450.0))
  1576. @pytest.mark.asyncio
  1577. @pytest.mark.integration
  1578. async def test_spool_level_zero_spool_weight_not_treated_as_missing(
  1579. self, async_client: AsyncClient, spoolman_settings
  1580. ):
  1581. """spool.spool_weight=0 is valid (0g tare), not treated as missing/fallback."""
  1582. sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0, spool_level_spool_weight=0)
  1583. mock_client = _mock_spoolman_client()
  1584. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1585. mock_client.update_spool = AsyncMock(return_value=sm_spool)
  1586. with (
  1587. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
  1588. patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
  1589. ):
  1590. resp = await async_client.post(
  1591. f"{API}/scale/update-spool-weight",
  1592. json={"spool_id": 42, "weight_grams": 750},
  1593. )
  1594. assert resp.status_code == 200
  1595. # remaining = 750 - 0 = 750; weight_used = 1000 - 750 = 250
  1596. assert resp.json()["weight_used"] == pytest.approx(250.0)
  1597. mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(750.0))
  1598. @pytest.mark.asyncio
  1599. @pytest.mark.integration
  1600. async def test_both_levels_none_uses_250g_fallback_and_warns(self, async_client: AsyncClient, spoolman_settings):
  1601. """When both spool_weight and filament.spool_weight are None, 250g fallback is used with a warning."""
  1602. sm_spool = {"id": 42, "filament": {"weight": 1000.0, "spool_weight": None}, "used_weight": 0.0}
  1603. mock_client = _mock_spoolman_client()
  1604. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1605. mock_client.update_spool = AsyncMock(return_value=sm_spool)
  1606. with (
  1607. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
  1608. patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
  1609. ):
  1610. resp = await async_client.post(
  1611. f"{API}/scale/update-spool-weight",
  1612. json={"spool_id": 42, "weight_grams": 750},
  1613. )
  1614. assert resp.status_code == 200
  1615. # remaining = 750 - 250 = 500; weight_used = 1000 - 500 = 500
  1616. assert resp.json()["weight_used"] == pytest.approx(500.0)
  1617. assert resp.json().get("warnings")
  1618. class TestTagScannedSpoolmanFallback:
  1619. """nfc/tag-scanned falls back to Spoolman when local DB has no match."""
  1620. @pytest.mark.asyncio
  1621. @pytest.mark.integration
  1622. async def test_spoolman_fallback_on_local_miss(self, async_client: AsyncClient, spoolman_settings):
  1623. raw_spool = {
  1624. "id": 5,
  1625. "filament": {
  1626. "material": "PETG",
  1627. "name": "PETG Basic",
  1628. "color_hex": "00FF00",
  1629. "weight": 1000,
  1630. "vendor": {"name": "Polymaker"},
  1631. },
  1632. "used_weight": 100.0,
  1633. "archived": False,
  1634. "registered": "2024-01-01T00:00:00+00:00",
  1635. "extra": {"tag": '"DEADBEEF12345678"'},
  1636. }
  1637. mock_client = _mock_spoolman_client()
  1638. mock_client.get_spools = AsyncMock(return_value=[raw_spool])
  1639. mock_client.find_spool_by_tag = AsyncMock(return_value=raw_spool)
  1640. with (
  1641. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1642. patch(
  1643. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  1644. new_callable=AsyncMock,
  1645. return_value=None,
  1646. ),
  1647. patch(
  1648. "backend.app.services.spoolman.get_spoolman_client",
  1649. AsyncMock(return_value=mock_client),
  1650. ),
  1651. patch(
  1652. "backend.app.services.spoolman.init_spoolman_client",
  1653. AsyncMock(return_value=mock_client),
  1654. ),
  1655. ):
  1656. mock_ws.broadcast = AsyncMock()
  1657. resp = await async_client.post(
  1658. f"{API}/nfc/tag-scanned",
  1659. json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"},
  1660. )
  1661. assert resp.status_code == 200
  1662. data = resp.json()
  1663. assert data["matched"] is True
  1664. assert data["spool_id"] == 5
  1665. mock_ws.broadcast.assert_called_once()
  1666. msg = mock_ws.broadcast.call_args[0][0]
  1667. assert msg["type"] == "spoolbuddy_tag_matched"
  1668. assert msg["spool"]["id"] == 5
  1669. assert msg["spool"]["material"] == "PETG"
  1670. @pytest.mark.asyncio
  1671. @pytest.mark.integration
  1672. async def test_spoolman_fallback_unknown_when_no_spoolman_match(self, async_client: AsyncClient, spoolman_settings):
  1673. """Unknown tag broadcast when both local DB and Spoolman miss."""
  1674. mock_client = _mock_spoolman_client()
  1675. mock_client.get_spools = AsyncMock(return_value=[])
  1676. mock_client.find_spool_by_tag = AsyncMock(return_value=None)
  1677. with (
  1678. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1679. patch(
  1680. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  1681. new_callable=AsyncMock,
  1682. return_value=None,
  1683. ),
  1684. patch(
  1685. "backend.app.services.spoolman.get_spoolman_client",
  1686. AsyncMock(return_value=mock_client),
  1687. ),
  1688. patch(
  1689. "backend.app.services.spoolman.init_spoolman_client",
  1690. AsyncMock(return_value=mock_client),
  1691. ),
  1692. ):
  1693. mock_ws.broadcast = AsyncMock()
  1694. resp = await async_client.post(
  1695. f"{API}/nfc/tag-scanned",
  1696. json={"device_id": "sb-1", "tag_uid": "UNKNOWN0000000FF"},
  1697. )
  1698. assert resp.status_code == 200
  1699. data = resp.json()
  1700. assert data["matched"] is False
  1701. assert data["spool_id"] is None
  1702. mock_ws.broadcast.assert_called_once()
  1703. msg = mock_ws.broadcast.call_args[0][0]
  1704. assert msg["type"] == "spoolbuddy_unknown_tag"
  1705. @pytest.mark.asyncio
  1706. @pytest.mark.integration
  1707. async def test_malformed_spoolman_data_degrades_gracefully(self, async_client: AsyncClient, spoolman_settings):
  1708. """ValueError from _map_spoolman_spool (e.g. spool_id=0) must return matched=False without broadcasting unknown_tag."""
  1709. bad_spool = {
  1710. "id": 0, # _map_spoolman_spool raises ValueError for id <= 0
  1711. "filament": {"material": "PLA", "name": "PLA Basic", "color_hex": "FF0000", "weight": 1000},
  1712. "used_weight": 0.0,
  1713. "archived": False,
  1714. "registered": "2024-01-01T00:00:00Z",
  1715. "extra": {"tag": '"DEADBEEF12345678"'},
  1716. }
  1717. mock_client = _mock_spoolman_client()
  1718. mock_client.find_spool_by_tag = AsyncMock(return_value=bad_spool)
  1719. with (
  1720. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1721. patch(
  1722. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  1723. new_callable=AsyncMock,
  1724. return_value=None,
  1725. ),
  1726. patch(
  1727. "backend.app.services.spoolman.get_spoolman_client",
  1728. AsyncMock(return_value=mock_client),
  1729. ),
  1730. patch(
  1731. "backend.app.services.spoolman.init_spoolman_client",
  1732. AsyncMock(return_value=mock_client),
  1733. ),
  1734. ):
  1735. mock_ws.broadcast = AsyncMock()
  1736. resp = await async_client.post(
  1737. f"{API}/nfc/tag-scanned",
  1738. json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"},
  1739. )
  1740. assert resp.status_code == 200
  1741. data = resp.json()
  1742. assert data["matched"] is False
  1743. assert data["spool_id"] is None
  1744. # No broadcast: UI must not get a spurious unknown_tag event on Spoolman data errors
  1745. mock_ws.broadcast.assert_not_called()
  1746. @pytest.mark.asyncio
  1747. @pytest.mark.integration
  1748. async def test_local_match_skips_spoolman(self, async_client: AsyncClient, spool_factory):
  1749. """When local DB matches, Spoolman is never queried."""
  1750. spool = await spool_factory(tag_uid="AABB1122", material="PLA")
  1751. mock_spool = MagicMock()
  1752. mock_spool.id = spool.id
  1753. mock_spool.material = spool.material
  1754. mock_spool.subtype = spool.subtype
  1755. mock_spool.color_name = spool.color_name
  1756. mock_spool.rgba = spool.rgba
  1757. mock_spool.brand = spool.brand
  1758. mock_spool.label_weight = spool.label_weight
  1759. mock_spool.core_weight = spool.core_weight
  1760. mock_spool.weight_used = spool.weight_used
  1761. with (
  1762. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1763. patch(
  1764. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  1765. new_callable=AsyncMock,
  1766. return_value=mock_spool,
  1767. ),
  1768. ):
  1769. mock_ws.broadcast = AsyncMock()
  1770. resp = await async_client.post(
  1771. f"{API}/nfc/tag-scanned",
  1772. json={"device_id": "sb-1", "tag_uid": "AABB1122"},
  1773. )
  1774. assert resp.status_code == 200
  1775. data = resp.json()
  1776. assert data["matched"] is True
  1777. assert data["spool_id"] == spool.id
  1778. # ============================================================================
  1779. # NFC write-tag / write-result — Spoolman-aware
  1780. # ============================================================================
  1781. def _full_spoolman_spool(spool_id: int) -> dict:
  1782. """Complete Spoolman spool dict sufficient for NDEF encoding."""
  1783. return {
  1784. "id": spool_id,
  1785. "filament": {
  1786. "material": "PLA",
  1787. "name": "PLA Basic",
  1788. "color_hex": "FF0000",
  1789. "weight": 1000.0,
  1790. "spool_weight": 196.0,
  1791. "vendor": {"name": "Bambu Lab"},
  1792. },
  1793. "used_weight": 0.0,
  1794. "archived": False,
  1795. "registered": "2024-01-01T00:00:00Z",
  1796. }
  1797. class TestNfcWriteTagSpoolman:
  1798. """nfc/write-tag encodes the Spoolman spool in Spoolman mode."""
  1799. @pytest.mark.asyncio
  1800. @pytest.mark.integration
  1801. async def test_spoolman_spool_queued_when_local_miss(
  1802. self, async_client: AsyncClient, device_factory, spoolman_settings
  1803. ):
  1804. """write-tag encodes NDEF from Spoolman data when spool not in local DB."""
  1805. await device_factory(device_id="sb-write-sm")
  1806. sm_spool = _full_spoolman_spool(77)
  1807. mock_client = _mock_spoolman_client()
  1808. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1809. with (
  1810. patch(
  1811. "backend.app.services.spoolman.get_spoolman_client",
  1812. AsyncMock(return_value=mock_client),
  1813. ),
  1814. patch(
  1815. "backend.app.services.spoolman.init_spoolman_client",
  1816. AsyncMock(return_value=mock_client),
  1817. ),
  1818. ):
  1819. resp = await async_client.post(
  1820. f"{API}/nfc/write-tag",
  1821. json={"device_id": "sb-write-sm", "spool_id": 77},
  1822. )
  1823. assert resp.status_code == 200
  1824. assert resp.json()["status"] == "queued"
  1825. mock_client.get_spool.assert_called_once_with(77)
  1826. @pytest.mark.asyncio
  1827. @pytest.mark.integration
  1828. async def test_data_origin_spoolman_stored_in_payload(
  1829. self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
  1830. ):
  1831. """Pending write payload records data_origin=spoolman for Spoolman spools."""
  1832. import json as _json
  1833. device = await device_factory(device_id="sb-origin")
  1834. sm_spool = _full_spoolman_spool(88)
  1835. mock_client = _mock_spoolman_client()
  1836. mock_client.get_spool = AsyncMock(return_value=sm_spool)
  1837. with (
  1838. patch(
  1839. "backend.app.services.spoolman.get_spoolman_client",
  1840. AsyncMock(return_value=mock_client),
  1841. ),
  1842. patch(
  1843. "backend.app.services.spoolman.init_spoolman_client",
  1844. AsyncMock(return_value=mock_client),
  1845. ),
  1846. ):
  1847. await async_client.post(
  1848. f"{API}/nfc/write-tag",
  1849. json={"device_id": "sb-origin", "spool_id": 88},
  1850. )
  1851. await db_session.refresh(device)
  1852. payload = _json.loads(device.pending_write_payload)
  1853. assert payload["data_origin"] == "spoolman"
  1854. assert payload["spool_id"] == 88
  1855. assert "ndef_data_hex" in payload
  1856. assert int.from_bytes(bytes.fromhex(payload["ndef_v2_hex"])[26:28], "big") == 2004
  1857. @pytest.mark.asyncio
  1858. @pytest.mark.integration
  1859. async def test_404_when_spoolman_has_no_such_spool(
  1860. self, async_client: AsyncClient, device_factory, spoolman_settings
  1861. ):
  1862. """404 returned when the spool is missing from Spoolman."""
  1863. await device_factory(device_id="sb-miss")
  1864. mock_client = _mock_spoolman_client()
  1865. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found"))
  1866. with (
  1867. patch(
  1868. "backend.app.services.spoolman.get_spoolman_client",
  1869. AsyncMock(return_value=mock_client),
  1870. ),
  1871. patch(
  1872. "backend.app.services.spoolman.init_spoolman_client",
  1873. AsyncMock(return_value=mock_client),
  1874. ),
  1875. ):
  1876. resp = await async_client.post(
  1877. f"{API}/nfc/write-tag",
  1878. json={"device_id": "sb-miss", "spool_id": 9999},
  1879. )
  1880. assert resp.status_code == 404
  1881. @pytest.mark.asyncio
  1882. @pytest.mark.integration
  1883. async def test_spoolman_spool_wins_over_a_leftover_built_in_spool(
  1884. self, async_client: AsyncClient, device_factory, db_session, spool_factory, spoolman_settings
  1885. ):
  1886. """Built-in spools are kept after switching to Spoolman; one with the
  1887. same id as the Spoolman spool must not be the one written to the tag."""
  1888. import json as _json
  1889. leftover = await spool_factory(material="PETG")
  1890. device = await device_factory(device_id="sb-leftover")
  1891. mock_client = _mock_spoolman_client()
  1892. mock_client.get_spool = AsyncMock(return_value=_full_spoolman_spool(leftover.id))
  1893. with (
  1894. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
  1895. patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
  1896. ):
  1897. resp = await async_client.post(
  1898. f"{API}/nfc/write-tag",
  1899. json={"device_id": "sb-leftover", "spool_id": leftover.id},
  1900. )
  1901. assert resp.status_code == 200, resp.text
  1902. mock_client.get_spool.assert_called_once_with(leftover.id)
  1903. await db_session.refresh(device)
  1904. assert _json.loads(device.pending_write_payload)["data_origin"] == "spoolman"
  1905. @pytest.mark.asyncio
  1906. @pytest.mark.integration
  1907. async def test_local_spool_used_when_present(self, async_client: AsyncClient, device_factory, spool_factory):
  1908. """Local DB spool is encoded directly without contacting Spoolman."""
  1909. await device_factory(device_id="sb-local-write")
  1910. spool = await spool_factory(material="PETG")
  1911. resp = await async_client.post(
  1912. f"{API}/nfc/write-tag",
  1913. json={"device_id": "sb-local-write", "spool_id": spool.id},
  1914. )
  1915. assert resp.status_code == 200
  1916. assert resp.json()["status"] == "queued"
  1917. class TestNfcWriteResultSpoolman:
  1918. """nfc/write-result updates Spoolman extra.tag on success for Spoolman spools."""
  1919. @pytest.mark.asyncio
  1920. @pytest.mark.integration
  1921. async def test_success_updates_spoolman_extra_tag(
  1922. self, async_client: AsyncClient, device_factory, spoolman_settings
  1923. ):
  1924. """Successful write for a Spoolman spool calls merge_spool_extra with extra.tag."""
  1925. import json as _json
  1926. await device_factory(
  1927. device_id="sb-wr-sm",
  1928. pending_command="write_tag",
  1929. pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  1930. )
  1931. mock_client = _mock_spoolman_client()
  1932. mock_client.merge_spool_extra = AsyncMock(return_value={"id": 55})
  1933. with (
  1934. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1935. patch(
  1936. "backend.app.services.spoolman.get_spoolman_client",
  1937. AsyncMock(return_value=mock_client),
  1938. ),
  1939. patch(
  1940. "backend.app.services.spoolman.init_spoolman_client",
  1941. AsyncMock(return_value=mock_client),
  1942. ),
  1943. ):
  1944. mock_ws.broadcast = AsyncMock()
  1945. resp = await async_client.post(
  1946. f"{API}/nfc/write-result",
  1947. json={
  1948. "device_id": "sb-wr-sm",
  1949. "spool_id": 55,
  1950. "tag_uid": "AABBCCDD11223344",
  1951. "success": True,
  1952. },
  1953. )
  1954. assert resp.status_code == 200
  1955. mock_client.merge_spool_extra.assert_called_once_with(55, {"tag": '"AABBCCDD11223344"'})
  1956. msg = mock_ws.broadcast.call_args[0][0]
  1957. assert msg["type"] == "spoolbuddy_tag_written"
  1958. assert msg["tag_uid"] == "AABBCCDD11223344"
  1959. @pytest.mark.asyncio
  1960. @pytest.mark.integration
  1961. async def test_failure_does_not_call_spoolman(self, async_client: AsyncClient, device_factory, spoolman_settings):
  1962. """Failed write never calls Spoolman update."""
  1963. import json as _json
  1964. await device_factory(
  1965. device_id="sb-wr-fail",
  1966. pending_command="write_tag",
  1967. pending_write_payload=_json.dumps({"spool_id": 66, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  1968. )
  1969. mock_client = _mock_spoolman_client()
  1970. with (
  1971. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  1972. patch(
  1973. "backend.app.services.spoolman.get_spoolman_client",
  1974. AsyncMock(return_value=mock_client),
  1975. ),
  1976. ):
  1977. mock_ws.broadcast = AsyncMock()
  1978. resp = await async_client.post(
  1979. f"{API}/nfc/write-result",
  1980. json={
  1981. "device_id": "sb-wr-fail",
  1982. "spool_id": 66,
  1983. "tag_uid": "AABBCCDD11223344",
  1984. "success": False,
  1985. "message": "write timeout",
  1986. },
  1987. )
  1988. assert resp.status_code == 200
  1989. mock_client.update_spool.assert_not_called()
  1990. msg = mock_ws.broadcast.call_args[0][0]
  1991. assert msg["type"] == "spoolbuddy_tag_write_failed"
  1992. @pytest.mark.asyncio
  1993. @pytest.mark.integration
  1994. async def test_success_local_spool_writes_to_db(
  1995. self, async_client: AsyncClient, device_factory, spool_factory, db_session
  1996. ):
  1997. """Successful write for a local spool still updates local DB tag_uid."""
  1998. import json as _json
  1999. spool = await spool_factory()
  2000. await device_factory(
  2001. device_id="sb-wr-local",
  2002. pending_command="write_tag",
  2003. pending_write_payload=_json.dumps(
  2004. {"spool_id": spool.id, "ndef_data_hex": "deadbeef", "data_origin": "local"}
  2005. ),
  2006. )
  2007. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  2008. mock_ws.broadcast = AsyncMock()
  2009. resp = await async_client.post(
  2010. f"{API}/nfc/write-result",
  2011. json={
  2012. "device_id": "sb-wr-local",
  2013. "spool_id": spool.id,
  2014. "tag_uid": "DEADBEEF12345678",
  2015. "success": True,
  2016. },
  2017. )
  2018. assert resp.status_code == 200
  2019. await db_session.refresh(spool)
  2020. assert spool.tag_uid == "DEADBEEF12345678"
  2021. assert spool.tag_type == "ntag"
  2022. # ============================================================================
  2023. # Security fix tests — write-tag ValueError + write-result exception safety
  2024. # ============================================================================
  2025. class TestNfcWriteTagSpoolmanSecurityFixes:
  2026. """Regression tests for security fixes in nfc/write-tag Spoolman path."""
  2027. @pytest.mark.asyncio
  2028. @pytest.mark.integration
  2029. async def test_invalid_spoolman_spool_id_returns_502(
  2030. self, async_client: AsyncClient, device_factory, spoolman_settings
  2031. ):
  2032. """Malformed Spoolman spool (invalid id=0) raises 502, not 404 — spool exists but is bad data."""
  2033. await device_factory(device_id="sb-invalid-id")
  2034. # Spoolman returns spool with id=0 (invalid — caught by _map_spoolman_spool guard)
  2035. bad_spool = {**_full_spoolman_spool(1), "id": 0}
  2036. mock_client = _mock_spoolman_client()
  2037. mock_client.get_spool = AsyncMock(return_value=bad_spool)
  2038. with (
  2039. patch(
  2040. "backend.app.services.spoolman.get_spoolman_client",
  2041. AsyncMock(return_value=mock_client),
  2042. ),
  2043. patch(
  2044. "backend.app.services.spoolman.init_spoolman_client",
  2045. AsyncMock(return_value=mock_client),
  2046. ),
  2047. ):
  2048. resp = await async_client.post(
  2049. f"{API}/nfc/write-tag",
  2050. json={"device_id": "sb-invalid-id", "spool_id": 99},
  2051. )
  2052. # 502: spool exists in Spoolman but its data is malformed — not a "not found"
  2053. assert resp.status_code == 502
  2054. @pytest.mark.asyncio
  2055. @pytest.mark.integration
  2056. async def test_oversized_label_weight_does_not_crash(
  2057. self, async_client: AsyncClient, device_factory, spoolman_settings
  2058. ):
  2059. """label_weight > 65535 from Spoolman must not crash with struct.error."""
  2060. await device_factory(device_id="sb-overflow")
  2061. big_weight_spool = {
  2062. **_full_spoolman_spool(42),
  2063. "filament": {**_full_spoolman_spool(42)["filament"], "weight": 70000},
  2064. }
  2065. mock_client = _mock_spoolman_client()
  2066. mock_client.get_spool = AsyncMock(return_value=big_weight_spool)
  2067. with (
  2068. patch(
  2069. "backend.app.services.spoolman.get_spoolman_client",
  2070. AsyncMock(return_value=mock_client),
  2071. ),
  2072. patch(
  2073. "backend.app.services.spoolman.init_spoolman_client",
  2074. AsyncMock(return_value=mock_client),
  2075. ),
  2076. ):
  2077. resp = await async_client.post(
  2078. f"{API}/nfc/write-tag",
  2079. json={"device_id": "sb-overflow", "spool_id": 42},
  2080. )
  2081. assert resp.status_code == 200
  2082. assert resp.json()["status"] == "queued"
  2083. class TestNfcWriteResultSpoolmanSecurityFixes:
  2084. """Regression tests for transaction safety in nfc/write-result Spoolman path."""
  2085. @pytest.mark.asyncio
  2086. @pytest.mark.integration
  2087. async def test_spoolman_client_exception_still_clears_device_state(
  2088. self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
  2089. ):
  2090. """If Spoolman client raises, device pending_command is still cleared in DB."""
  2091. import json as _json
  2092. device = await device_factory(
  2093. device_id="sb-exc-safe",
  2094. pending_command="write_tag",
  2095. pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  2096. )
  2097. mock_client = _mock_spoolman_client()
  2098. mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused"))
  2099. with (
  2100. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  2101. patch(
  2102. "backend.app.services.spoolman.get_spoolman_client",
  2103. AsyncMock(return_value=mock_client),
  2104. ),
  2105. patch(
  2106. "backend.app.services.spoolman.init_spoolman_client",
  2107. AsyncMock(return_value=mock_client),
  2108. ),
  2109. ):
  2110. mock_ws.broadcast = AsyncMock()
  2111. resp = await async_client.post(
  2112. f"{API}/nfc/write-result",
  2113. json={
  2114. "device_id": "sb-exc-safe",
  2115. "spool_id": 77,
  2116. "tag_uid": "AABBCCDD11223344",
  2117. "success": True,
  2118. },
  2119. )
  2120. # 502: tag written to NFC but Spoolman link failed (not best-effort — caller must retry)
  2121. assert resp.status_code == 502
  2122. # Device state must be cleared despite the exception (no spurious re-write)
  2123. await db_session.refresh(device)
  2124. assert device.pending_command is None
  2125. assert device.pending_write_payload is None
  2126. # Failure broadcast fires so the UI can show the error
  2127. msg = mock_ws.broadcast.call_args[0][0]
  2128. assert msg["type"] == "spoolbuddy_tag_link_failed"
  2129. assert msg["reason"] == "spoolman_link_failed"
  2130. @pytest.mark.asyncio
  2131. @pytest.mark.integration
  2132. async def test_spoolman_not_found_error_broadcasts_link_failed(
  2133. self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
  2134. ):
  2135. """SpoolmanNotFoundError from merge_spool_extra must clear device state and broadcast link_failed."""
  2136. import json as _json
  2137. device = await device_factory(
  2138. device_id="sb-notfound",
  2139. pending_command="write_tag",
  2140. pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  2141. )
  2142. mock_client = _mock_spoolman_client()
  2143. mock_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 55 not found"))
  2144. with (
  2145. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  2146. patch(
  2147. "backend.app.services.spoolman.get_spoolman_client",
  2148. AsyncMock(return_value=mock_client),
  2149. ),
  2150. patch(
  2151. "backend.app.services.spoolman.init_spoolman_client",
  2152. AsyncMock(return_value=mock_client),
  2153. ),
  2154. ):
  2155. mock_ws.broadcast = AsyncMock()
  2156. resp = await async_client.post(
  2157. f"{API}/nfc/write-result",
  2158. json={
  2159. "device_id": "sb-notfound",
  2160. "spool_id": 55,
  2161. "tag_uid": "AABBCCDD11223344",
  2162. "success": True,
  2163. },
  2164. )
  2165. assert resp.status_code == 502
  2166. await db_session.refresh(device)
  2167. assert device.pending_command is None
  2168. assert device.pending_write_payload is None
  2169. msg = mock_ws.broadcast.call_args[0][0]
  2170. assert msg["type"] == "spoolbuddy_tag_link_failed"
  2171. assert msg["spool_id"] == 55
  2172. assert msg["reason"] == "spoolman_link_failed"
  2173. class TestNfcWriteResultOrphanedSpool:
  2174. """nfc/write-result when the local spool was deleted between write-queue and write-result."""
  2175. @pytest.mark.asyncio
  2176. @pytest.mark.integration
  2177. async def test_local_spool_deleted_before_write_back(self, async_client: AsyncClient, device_factory, db_session):
  2178. """When local spool is deleted between write-queue and write-result, return linked=False and broadcast link_failed."""
  2179. import json as _json
  2180. device = await device_factory(
  2181. device_id="sb-orphan",
  2182. pending_command="write_tag",
  2183. pending_write_payload=_json.dumps(
  2184. {
  2185. "spool_id": 99999, # non-existent spool
  2186. "ndef_data_hex": "aabbccdd",
  2187. "data_origin": "local",
  2188. }
  2189. ),
  2190. )
  2191. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  2192. mock_ws.broadcast = AsyncMock()
  2193. resp = await async_client.post(
  2194. f"{API}/nfc/write-result",
  2195. json={"device_id": device.device_id, "spool_id": 99999, "success": True, "tag_uid": "AABBCCDD"},
  2196. )
  2197. assert resp.status_code == 200
  2198. data = resp.json()
  2199. assert data["linked"] is False
  2200. # pending command should be cleared
  2201. await db_session.refresh(device)
  2202. assert device.pending_command is None
  2203. # broadcast should be spoolbuddy_tag_link_failed
  2204. broadcast_calls = mock_ws.broadcast.call_args_list
  2205. link_failed = [c[0][0] for c in broadcast_calls if c[0][0].get("type") == "spoolbuddy_tag_link_failed"]
  2206. assert len(link_failed) >= 1
  2207. assert link_failed[0]["reason"] == "spool_not_found"
  2208. class TestNfcWriteResultInputValidation:
  2209. """Input validation and JSON safety for nfc/write-result."""
  2210. @pytest.mark.asyncio
  2211. @pytest.mark.integration
  2212. async def test_tag_uid_too_long_rejected(self, async_client: AsyncClient, device_factory):
  2213. """tag_uid longer than 32 chars must be rejected with 422."""
  2214. import json as _json
  2215. await device_factory(
  2216. device_id="sb-uid-long",
  2217. pending_command="write_tag",
  2218. pending_write_payload=_json.dumps({"spool_id": 1, "ndef_data_hex": "dead", "data_origin": "local"}),
  2219. )
  2220. resp = await async_client.post(
  2221. f"{API}/nfc/write-result",
  2222. json={
  2223. "device_id": "sb-uid-long",
  2224. "spool_id": 1,
  2225. "tag_uid": "A" * 65,
  2226. "success": True,
  2227. },
  2228. )
  2229. assert resp.status_code == 422
  2230. @pytest.mark.asyncio
  2231. @pytest.mark.integration
  2232. async def test_malformed_pending_payload_falls_back_to_local(
  2233. self, async_client: AsyncClient, device_factory, spool_factory, db_session
  2234. ):
  2235. """Corrupted pending_write_payload JSON falls back to local mode gracefully."""
  2236. spool = await spool_factory()
  2237. await device_factory(
  2238. device_id="sb-corrupt-json",
  2239. pending_command="write_tag",
  2240. pending_write_payload="{not valid json!!!",
  2241. )
  2242. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  2243. mock_ws.broadcast = AsyncMock()
  2244. resp = await async_client.post(
  2245. f"{API}/nfc/write-result",
  2246. json={
  2247. "device_id": "sb-corrupt-json",
  2248. "spool_id": spool.id,
  2249. "tag_uid": "DEADBEEF12345678",
  2250. "success": True,
  2251. },
  2252. )
  2253. # Must return 200, not 500
  2254. assert resp.status_code == 200
  2255. # Falls back to local mode — tag written to DB
  2256. await db_session.refresh(spool)
  2257. assert spool.tag_uid == "DEADBEEF12345678"
  2258. # ============================================================================
  2259. # B1: NFC write-tag warnings appear in response body
  2260. # ============================================================================
  2261. class TestNfcWriteTagWarningsBody:
  2262. """B1: resp.json()['warnings'] is populated when Spoolman fields are absent."""
  2263. @pytest.mark.asyncio
  2264. @pytest.mark.integration
  2265. async def test_warnings_returned_for_missing_color_and_temp(
  2266. self, async_client: AsyncClient, device_factory, spoolman_settings
  2267. ):
  2268. """Both color_name=None and settings_extruder_temp=None produce 2 warnings."""
  2269. await device_factory(device_id="sb-warn-b1")
  2270. # Spoolman spool with no color_name or nozzle temp
  2271. sparse_spool = {
  2272. "id": 99,
  2273. "filament": {
  2274. "material": "PLA",
  2275. "name": "PLA Basic",
  2276. "color_hex": "808080",
  2277. # color_name absent → None after mapping
  2278. # settings_extruder_temp absent → nozzle_temp_min=None
  2279. "weight": 1000.0,
  2280. "vendor": {"name": "Bambu Lab"},
  2281. },
  2282. "used_weight": 0.0,
  2283. "archived": False,
  2284. "registered": "2024-01-01T00:00:00Z",
  2285. }
  2286. mock_client = _mock_spoolman_client()
  2287. mock_client.get_spool = AsyncMock(return_value=sparse_spool)
  2288. with (
  2289. patch(
  2290. "backend.app.services.spoolman.get_spoolman_client",
  2291. AsyncMock(return_value=mock_client),
  2292. ),
  2293. patch(
  2294. "backend.app.services.spoolman.init_spoolman_client",
  2295. AsyncMock(return_value=mock_client),
  2296. ),
  2297. ):
  2298. resp = await async_client.post(
  2299. f"{API}/nfc/write-tag",
  2300. json={"device_id": "sb-warn-b1", "spool_id": 99},
  2301. )
  2302. assert resp.status_code == 200
  2303. body = resp.json()
  2304. assert "warnings" in body, "Response should contain 'warnings' key when fields are absent"
  2305. warnings = body["warnings"]
  2306. assert len(warnings) >= 2, f"Expected at least 2 warnings for missing color_name + nozzle_temp, got: {warnings}"
  2307. # Confirm the specific fields are mentioned
  2308. warn_text = " ".join(warnings)
  2309. assert "color_name" in warn_text
  2310. assert "nozzle_temp" in warn_text
  2311. @pytest.mark.asyncio
  2312. @pytest.mark.integration
  2313. async def test_no_warnings_key_when_all_fields_present(
  2314. self, async_client: AsyncClient, device_factory, spoolman_settings
  2315. ):
  2316. """No 'warnings' key in response when all fields are populated."""
  2317. await device_factory(device_id="sb-nowarn")
  2318. full_spool = _full_spoolman_spool(100)
  2319. # Add color_name and extruder temp
  2320. full_spool["filament"]["color_name"] = "Red"
  2321. full_spool["filament"]["settings_extruder_temp"] = 220
  2322. mock_client = _mock_spoolman_client()
  2323. mock_client.get_spool = AsyncMock(return_value=full_spool)
  2324. with (
  2325. patch(
  2326. "backend.app.services.spoolman.get_spoolman_client",
  2327. AsyncMock(return_value=mock_client),
  2328. ),
  2329. patch(
  2330. "backend.app.services.spoolman.init_spoolman_client",
  2331. AsyncMock(return_value=mock_client),
  2332. ),
  2333. ):
  2334. resp = await async_client.post(
  2335. f"{API}/nfc/write-tag",
  2336. json={"device_id": "sb-nowarn", "spool_id": 100},
  2337. )
  2338. assert resp.status_code == 200
  2339. body = resp.json()
  2340. assert "warnings" not in body or body["warnings"] == []
  2341. # ============================================================================
  2342. # B5: Exception text scrubbed from WebSocket broadcast message
  2343. # ============================================================================
  2344. class TestNfcWriteResultExceptionScrubbing:
  2345. """B5: Internal exception details must not appear in WebSocket 'message' field."""
  2346. @pytest.mark.asyncio
  2347. @pytest.mark.integration
  2348. async def test_exception_text_not_leaked_in_ws_message(
  2349. self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
  2350. ):
  2351. """When Spoolman merge raises, WS message is generic; 'connection refused' absent."""
  2352. import json as _json
  2353. await device_factory(
  2354. device_id="sb-scrub-b5",
  2355. pending_command="write_tag",
  2356. pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
  2357. )
  2358. mock_client = _mock_spoolman_client()
  2359. mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused to 192.168.1.1:7912"))
  2360. with (
  2361. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  2362. patch(
  2363. "backend.app.services.spoolman.get_spoolman_client",
  2364. AsyncMock(return_value=mock_client),
  2365. ),
  2366. patch(
  2367. "backend.app.services.spoolman.init_spoolman_client",
  2368. AsyncMock(return_value=mock_client),
  2369. ),
  2370. ):
  2371. mock_ws.broadcast = AsyncMock()
  2372. resp = await async_client.post(
  2373. f"{API}/nfc/write-result",
  2374. json={
  2375. "device_id": "sb-scrub-b5",
  2376. "spool_id": 77,
  2377. "tag_uid": "AABBCCDD11223344",
  2378. "success": True,
  2379. },
  2380. )
  2381. assert resp.status_code == 502
  2382. msg = mock_ws.broadcast.call_args[0][0]
  2383. assert msg["type"] == "spoolbuddy_tag_link_failed"
  2384. # Generic message — no internal exception details leaked
  2385. assert msg["message"] == "Spoolman link failed", f"Expected generic message but got: {msg['message']!r}"
  2386. assert msg["reason"] == "spoolman_link_failed"
  2387. assert "connection refused" not in str(msg), f"Exception text must not appear in WS message: {msg}"
  2388. assert "192.168.1" not in str(msg), f"Internal IP must not appear in WS message: {msg}"
  2389. # ============================================================================
  2390. # _get_spoolman_client_or_none: graceful degradation on ValueError during reinit
  2391. # ============================================================================
  2392. class TestSpoolmanClientOrNoneGraceful:
  2393. """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError."""
  2394. @pytest.mark.asyncio
  2395. @pytest.mark.integration
  2396. async def test_returns_none_when_init_raises_value_error(self, async_client: AsyncClient, db_session):
  2397. """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError,
  2398. so the device endpoint degrades gracefully instead of propagating a 500 error."""
  2399. from backend.app.models.settings import Settings
  2400. db_session.add(Settings(key="spoolman_enabled", value="true"))
  2401. db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
  2402. await db_session.commit()
  2403. with (
  2404. patch("backend.app.api.routes._spoolman_helpers.assert_safe_spoolman_url"),
  2405. patch(
  2406. "backend.app.services.spoolman.get_spoolman_client",
  2407. AsyncMock(return_value=None),
  2408. ),
  2409. patch(
  2410. "backend.app.services.spoolman.init_spoolman_client",
  2411. AsyncMock(side_effect=ValueError("invalid URL")),
  2412. ),
  2413. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  2414. ):
  2415. mock_ws.broadcast = AsyncMock()
  2416. # nfc/tag-scanned calls _get_spoolman_client_or_none; with None returned it
  2417. # must broadcast unknown_tag (not raise 500 due to ValueError propagating).
  2418. resp = await async_client.post(
  2419. f"{API}/nfc/tag-scanned",
  2420. json={"device_id": "sb-vale", "tag_uid": "AABBCCDD"},
  2421. )
  2422. # Must not be 500 — ValueError is caught and client returns None, degrading gracefully
  2423. assert resp.status_code == 200
  2424. data = resp.json()
  2425. assert data["matched"] is False