test_spoolbuddy_spoolman_nfc.py 15 KB

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