test_spoolbuddy_spoolman_nfc.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. """Integration tests for SpoolBuddy + Spoolman NFC fixes.
  2. Group 1 – tag-scanned broadcasts include tray_uuid in all WebSocket messages.
  3. Group 2 – PATCH /api/v1/spoolman/inventory/spools/{id}/tag endpoint.
  4. """
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from httpx import AsyncClient
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.models.settings import Settings
  10. from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
  11. SPOOLBUDDY_API = "/api/v1/spoolbuddy"
  12. INVENTORY_API = "/api/v1/spoolman/inventory"
  13. # ---------------------------------------------------------------------------
  14. # Shared helpers
  15. # ---------------------------------------------------------------------------
  16. @pytest.fixture
  17. async def spoolman_settings_local(db_session: AsyncSession):
  18. """Spoolman enabled, URL = spoolman.local (matches SpoolBuddy service patches)."""
  19. db_session.add(Settings(key="spoolman_enabled", value="true"))
  20. db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
  21. await db_session.commit()
  22. @pytest.fixture
  23. async def spoolman_settings_disabled(db_session: AsyncSession):
  24. """Spoolman disabled — used by tests that exercise the local-DB-only
  25. lookup path on /nfc/tag-scanned. With the mode-routing in the #1228
  26. follow-up, Spoolman is now consulted exclusively when enabled, so the
  27. local-DB broadcast tests have to disable Spoolman to reach the local
  28. code path.
  29. """
  30. db_session.add(Settings(key="spoolman_enabled", value="false"))
  31. await db_session.commit()
  32. @pytest.fixture
  33. async def spoolman_settings_inventory(db_session: AsyncSession):
  34. """Spoolman enabled, URL = localhost (matches inventory proxy patches)."""
  35. db_session.add(Settings(key="spoolman_enabled", value="true"))
  36. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  37. await db_session.commit()
  38. def _spoolman_spool(spool_id: int) -> dict:
  39. """Minimal Spoolman raw spool dict suitable for _map_spoolman_spool()."""
  40. return {
  41. "id": spool_id,
  42. "filament": {
  43. "material": "PLA",
  44. "name": "PLA Basic",
  45. "color_hex": "FF0000",
  46. "weight": 1000.0,
  47. "spool_weight": 196.0,
  48. "vendor": {"name": "Bambu Lab"},
  49. },
  50. "used_weight": 0.0,
  51. "archived": False,
  52. "registered": "2024-01-01T00:00:00Z",
  53. }
  54. def _mock_spoolman_client_local() -> MagicMock:
  55. client = MagicMock()
  56. client.base_url = "http://spoolman.local:7912"
  57. client.get_spools = AsyncMock(return_value=[])
  58. client.find_spool_by_tag = AsyncMock(return_value=None)
  59. client.merge_spool_extra = AsyncMock(return_value={})
  60. return client
  61. # ---------------------------------------------------------------------------
  62. # Group 1: broadcast tests — tray_uuid forwarded in all WS broadcasts
  63. # ---------------------------------------------------------------------------
  64. class TestTagScannedBroadcastsTrayUuid:
  65. """nfc/tag-scanned broadcasts include tray_uuid from the request payload."""
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_local_match_broadcast_includes_tray_uuid(
  69. self, async_client: AsyncClient, spoolman_settings_disabled
  70. ):
  71. """Local DB match broadcasts tray_uuid alongside tag_uid."""
  72. mock_local_spool = MagicMock()
  73. mock_local_spool.id = 1
  74. mock_local_spool.material = "PLA"
  75. mock_local_spool.subtype = None
  76. mock_local_spool.color_name = "Red"
  77. mock_local_spool.rgba = "FF0000FF"
  78. mock_local_spool.brand = "Bambu Lab"
  79. mock_local_spool.label_weight = 1000
  80. mock_local_spool.core_weight = 250
  81. mock_local_spool.weight_used = 0
  82. with (
  83. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  84. patch(
  85. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  86. new_callable=AsyncMock,
  87. return_value=mock_local_spool,
  88. ),
  89. ):
  90. mock_ws.broadcast = AsyncMock()
  91. resp = await async_client.post(
  92. f"{SPOOLBUDDY_API}/nfc/tag-scanned",
  93. json={
  94. "device_id": "sb-test",
  95. "tag_uid": "AABB1122334455FF",
  96. "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
  97. },
  98. )
  99. assert resp.status_code == 200
  100. assert resp.json()["matched"] is True
  101. mock_ws.broadcast.assert_called_once()
  102. msg = mock_ws.broadcast.call_args[0][0]
  103. assert msg["type"] == "spoolbuddy_tag_matched"
  104. assert msg["tag_uid"] == "AABB1122334455FF"
  105. assert msg["tray_uuid"] == "DEADBEEFDEADBEEFDEADBEEFDEADBEEF"
  106. @pytest.mark.asyncio
  107. @pytest.mark.integration
  108. async def test_spoolman_match_broadcast_includes_tray_uuid(
  109. self, async_client: AsyncClient, spoolman_settings_local
  110. ):
  111. """Spoolman fallback match broadcasts tray_uuid alongside tag_uid."""
  112. sm_spool = _spoolman_spool(5)
  113. sm_spool["extra"] = {"tag": '"DEADBEEFDEADBEEFDEADBEEFDEADBEEF"'}
  114. mock_client = _mock_spoolman_client_local()
  115. mock_client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
  116. with (
  117. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  118. patch(
  119. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  120. new_callable=AsyncMock,
  121. return_value=None,
  122. ),
  123. patch(
  124. "backend.app.services.spoolman.get_spoolman_client",
  125. AsyncMock(return_value=mock_client),
  126. ),
  127. patch(
  128. "backend.app.services.spoolman.init_spoolman_client",
  129. AsyncMock(return_value=mock_client),
  130. ),
  131. ):
  132. mock_ws.broadcast = AsyncMock()
  133. resp = await async_client.post(
  134. f"{SPOOLBUDDY_API}/nfc/tag-scanned",
  135. json={
  136. "device_id": "sb-test",
  137. "tag_uid": "AABB1122334455FF",
  138. "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
  139. },
  140. )
  141. assert resp.status_code == 200
  142. assert resp.json()["matched"] is True
  143. mock_ws.broadcast.assert_called_once()
  144. msg = mock_ws.broadcast.call_args[0][0]
  145. assert msg["type"] == "spoolbuddy_tag_matched"
  146. assert msg["tray_uuid"] == "DEADBEEFDEADBEEFDEADBEEFDEADBEEF"
  147. @pytest.mark.asyncio
  148. @pytest.mark.integration
  149. async def test_unknown_tag_broadcast_includes_tray_uuid(self, async_client: AsyncClient, spoolman_settings_local):
  150. """Unknown tag broadcast includes tray_uuid when Bambu spool is not yet linked."""
  151. mock_client = _mock_spoolman_client_local()
  152. mock_client.find_spool_by_tag = AsyncMock(return_value=None)
  153. with (
  154. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  155. patch(
  156. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  157. new_callable=AsyncMock,
  158. return_value=None,
  159. ),
  160. patch(
  161. "backend.app.services.spoolman.get_spoolman_client",
  162. AsyncMock(return_value=mock_client),
  163. ),
  164. patch(
  165. "backend.app.services.spoolman.init_spoolman_client",
  166. AsyncMock(return_value=mock_client),
  167. ),
  168. ):
  169. mock_ws.broadcast = AsyncMock()
  170. resp = await async_client.post(
  171. f"{SPOOLBUDDY_API}/nfc/tag-scanned",
  172. json={
  173. "device_id": "sb-test",
  174. "tag_uid": "AABB1122334455FF",
  175. "tray_uuid": "CAFEBABECAFEBABECAFEBABECAFEBABE",
  176. },
  177. )
  178. assert resp.status_code == 200
  179. assert resp.json()["matched"] is False
  180. mock_ws.broadcast.assert_called_once()
  181. msg = mock_ws.broadcast.call_args[0][0]
  182. assert msg["type"] == "spoolbuddy_unknown_tag"
  183. assert msg["tray_uuid"] == "CAFEBABECAFEBABECAFEBABECAFEBABE"
  184. @pytest.mark.asyncio
  185. @pytest.mark.integration
  186. async def test_unknown_tag_broadcast_tray_uuid_null_when_absent(
  187. self, async_client: AsyncClient, spoolman_settings_local
  188. ):
  189. """tray_uuid is None in the broadcast when the request omits it."""
  190. mock_client = _mock_spoolman_client_local()
  191. mock_client.find_spool_by_tag = AsyncMock(return_value=None)
  192. with (
  193. patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
  194. patch(
  195. "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
  196. new_callable=AsyncMock,
  197. return_value=None,
  198. ),
  199. patch(
  200. "backend.app.services.spoolman.get_spoolman_client",
  201. AsyncMock(return_value=mock_client),
  202. ),
  203. patch(
  204. "backend.app.services.spoolman.init_spoolman_client",
  205. AsyncMock(return_value=mock_client),
  206. ),
  207. ):
  208. mock_ws.broadcast = AsyncMock()
  209. resp = await async_client.post(
  210. f"{SPOOLBUDDY_API}/nfc/tag-scanned",
  211. json={"device_id": "sb-test", "tag_uid": "AABB1122334455FF"},
  212. )
  213. assert resp.status_code == 200
  214. assert resp.json()["matched"] is False
  215. mock_ws.broadcast.assert_called_once()
  216. msg = mock_ws.broadcast.call_args[0][0]
  217. assert msg["type"] == "spoolbuddy_unknown_tag"
  218. assert msg["tray_uuid"] is None
  219. # ---------------------------------------------------------------------------
  220. # Group 2: PATCH /spoolman/inventory/spools/{id}/tag endpoint
  221. # ---------------------------------------------------------------------------
  222. class TestLinkTagToSpoolmanSpool:
  223. """PATCH /spoolman/inventory/spools/{id}/tag writes an NFC tag into Spoolman extra.tag."""
  224. def _mock_client(self, spool_id: int) -> MagicMock:
  225. client = MagicMock()
  226. client.base_url = "http://localhost:7912"
  227. # get_all_spools returns empty list — no duplicate tags in Spoolman.
  228. client.get_all_spools = AsyncMock(return_value=[])
  229. client.get_spool = AsyncMock(return_value=_spoolman_spool(spool_id))
  230. client.update_spool_full = AsyncMock(return_value=_spoolman_spool(spool_id))
  231. return client
  232. @pytest.mark.asyncio
  233. @pytest.mark.integration
  234. async def test_link_tag_uid_writes_to_extra_tag(self, async_client: AsyncClient):
  235. """PATCH with tag_uid writes uppercased tag_uid to Spoolman extra.tag."""
  236. import json as _json
  237. mock_client = self._mock_client(42)
  238. with patch(
  239. "backend.app.api.routes.spoolman_inventory._get_client",
  240. AsyncMock(return_value=mock_client),
  241. ):
  242. resp = await async_client.patch(
  243. f"{INVENTORY_API}/spools/42/tag",
  244. json={"tag_uid": "aabb1122334455ff"},
  245. )
  246. assert resp.status_code == 200
  247. mock_client.update_spool_full.assert_called_once()
  248. _, kwargs = mock_client.update_spool_full.call_args
  249. assert kwargs.get("extra", {}).get("tag") == _json.dumps("AABB1122334455FF")
  250. @pytest.mark.asyncio
  251. @pytest.mark.integration
  252. async def test_tray_uuid_takes_precedence_over_tag_uid(self, async_client: AsyncClient):
  253. """tray_uuid takes precedence when both tag_uid and tray_uuid are provided."""
  254. import json as _json
  255. mock_client = self._mock_client(7)
  256. with patch(
  257. "backend.app.api.routes.spoolman_inventory._get_client",
  258. AsyncMock(return_value=mock_client),
  259. ):
  260. resp = await async_client.patch(
  261. f"{INVENTORY_API}/spools/7/tag",
  262. json={
  263. "tag_uid": "AABB1122334455FF",
  264. "tray_uuid": "deadbeefdeadbeefdeadbeefdeadbeef",
  265. },
  266. )
  267. assert resp.status_code == 200
  268. mock_client.update_spool_full.assert_called_once()
  269. _, kwargs = mock_client.update_spool_full.call_args
  270. assert kwargs.get("extra", {}).get("tag") == _json.dumps("DEADBEEFDEADBEEFDEADBEEFDEADBEEF")
  271. @pytest.mark.asyncio
  272. @pytest.mark.integration
  273. async def test_neither_tag_uid_nor_tray_uuid_returns_422(self, async_client: AsyncClient):
  274. """422 Unprocessable Entity when neither tag_uid nor tray_uuid is provided."""
  275. with patch(
  276. "backend.app.api.routes.spoolman_inventory._get_client",
  277. AsyncMock(return_value=MagicMock()),
  278. ):
  279. resp = await async_client.patch(
  280. f"{INVENTORY_API}/spools/1/tag",
  281. json={},
  282. )
  283. assert resp.status_code == 422
  284. @pytest.mark.asyncio
  285. @pytest.mark.integration
  286. async def test_spool_not_found_returns_404(self, async_client: AsyncClient):
  287. """404 when Spoolman reports the spool does not exist."""
  288. mock_client = MagicMock()
  289. mock_client.get_all_spools = AsyncMock(return_value=[])
  290. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 999 not found"))
  291. with patch(
  292. "backend.app.api.routes.spoolman_inventory._get_client",
  293. AsyncMock(return_value=mock_client),
  294. ):
  295. resp = await async_client.patch(
  296. f"{INVENTORY_API}/spools/999/tag",
  297. json={"tag_uid": "AABB1122334455FF"},
  298. )
  299. assert resp.status_code == 404
  300. @pytest.mark.asyncio
  301. @pytest.mark.integration
  302. async def test_spoolman_unavailable_returns_503(self, async_client: AsyncClient):
  303. """503 when Spoolman is unreachable during the tag link (duplicate check fails first)."""
  304. mock_client = MagicMock()
  305. mock_client.get_all_spools = AsyncMock(side_effect=SpoolmanUnavailableError("Spoolman down"))
  306. with patch(
  307. "backend.app.api.routes.spoolman_inventory._get_client",
  308. AsyncMock(return_value=mock_client),
  309. ):
  310. resp = await async_client.patch(
  311. f"{INVENTORY_API}/spools/42/tag",
  312. json={"tag_uid": "AABB1122334455FF"},
  313. )
  314. assert resp.status_code == 503
  315. @pytest.mark.asyncio
  316. @pytest.mark.integration
  317. async def test_returns_400_when_spoolman_disabled(self, async_client: AsyncClient):
  318. """400 when Spoolman integration is not enabled (no settings in DB)."""
  319. resp = await async_client.patch(
  320. f"{INVENTORY_API}/spools/42/tag",
  321. json={"tag_uid": "AABB1122334455FF"},
  322. )
  323. assert resp.status_code == 400
  324. @pytest.mark.asyncio
  325. @pytest.mark.integration
  326. async def test_4_byte_uid_writes_to_extra_tag(self, async_client: AsyncClient):
  327. """PATCH with 8-char (4-byte Bambu Lab) tag_uid writes correctly to Spoolman extra.tag."""
  328. import json as _json
  329. mock_client = self._mock_client(42)
  330. with patch(
  331. "backend.app.api.routes.spoolman_inventory._get_client",
  332. AsyncMock(return_value=mock_client),
  333. ):
  334. resp = await async_client.patch(
  335. f"{INVENTORY_API}/spools/42/tag",
  336. json={"tag_uid": "2728C17B"}, # 4-byte / 8-char Bambu Lab hardware UID
  337. )
  338. assert resp.status_code == 200
  339. mock_client.update_spool_full.assert_called_once()
  340. _, kwargs = mock_client.update_spool_full.call_args
  341. assert kwargs.get("extra", {}).get("tag") == _json.dumps("2728C17B")