test_spoolman_inventory_methods.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. """Unit tests for new SpoolmanClient inventory methods.
  2. Covers: get_spool, get_all_spools, delete_spool, set_spool_archived,
  3. update_spool_full, find_or_create_vendor, find_or_create_filament.
  4. """
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import httpx
  7. import pytest
  8. from backend.app.services.spoolman import SpoolmanClient, SpoolmanUnavailableError
  9. @pytest.fixture
  10. def client():
  11. return SpoolmanClient("http://localhost:7912")
  12. def _make_response(json_data, status_code=200):
  13. """Build a mock httpx response."""
  14. resp = MagicMock()
  15. resp.status_code = status_code
  16. resp.json.return_value = json_data
  17. resp.raise_for_status = MagicMock()
  18. return resp
  19. SAMPLE_SPOOL = {
  20. "id": 42,
  21. "remaining_weight": 750.0,
  22. "used_weight": 250.0,
  23. "archived": False,
  24. "filament": {"id": 7, "name": "PLA Basic", "material": "PLA"},
  25. }
  26. SAMPLE_FILAMENT = {
  27. "id": 7,
  28. "name": "PLA Basic",
  29. "material": "PLA",
  30. "color_hex": "FF0000",
  31. "weight": 1000.0,
  32. "vendor": {"id": 3, "name": "Bambu Lab"},
  33. }
  34. SAMPLE_VENDOR = {"id": 3, "name": "Bambu Lab"}
  35. # ---------------------------------------------------------------------------
  36. # get_spool
  37. # ---------------------------------------------------------------------------
  38. class TestGetSpool:
  39. @pytest.mark.asyncio
  40. async def test_returns_spool_dict_on_success(self, client):
  41. mock_http = AsyncMock()
  42. mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
  43. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  44. result = await client.get_spool(42)
  45. assert result == SAMPLE_SPOOL
  46. mock_http.request.assert_called_once_with("GET", "http://localhost:7912/api/v1/spool/42", json=None)
  47. @pytest.mark.asyncio
  48. async def test_raises_unavailable_on_http_error(self, client):
  49. from backend.app.services.spoolman import SpoolmanUnavailableError
  50. mock_http = AsyncMock()
  51. mock_http.request = AsyncMock(side_effect=Exception("not found"))
  52. with (
  53. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  54. pytest.raises(SpoolmanUnavailableError),
  55. ):
  56. await client.get_spool(99)
  57. @pytest.mark.asyncio
  58. async def test_raises_not_found_on_404_response(self, client):
  59. """get_spool raises SpoolmanNotFoundError when Spoolman returns HTTP 404 (PT-I3)."""
  60. from backend.app.services.spoolman import SpoolmanNotFoundError
  61. mock_http = AsyncMock()
  62. mock_http.request = AsyncMock(return_value=_make_response(None, status_code=404))
  63. with (
  64. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  65. pytest.raises(SpoolmanNotFoundError),
  66. ):
  67. await client.get_spool(99)
  68. @pytest.mark.asyncio
  69. async def test_raises_client_error_on_4xx_response(self, client):
  70. """get_spool raises SpoolmanClientError (not SpoolmanUnavailableError) on non-404 4xx (H2)."""
  71. from backend.app.services.spoolman import SpoolmanClientError
  72. mock_request = MagicMock()
  73. mock_request.url = "http://localhost:7912/api/v1/spool/42"
  74. mock_resp_obj = MagicMock()
  75. mock_resp_obj.status_code = 422
  76. mock_http = AsyncMock()
  77. resp = _make_response(None, status_code=422)
  78. resp.raise_for_status = MagicMock(
  79. side_effect=httpx.HTTPStatusError("Unprocessable", request=mock_request, response=mock_resp_obj)
  80. )
  81. mock_http.request = AsyncMock(return_value=resp)
  82. with (
  83. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  84. pytest.raises(SpoolmanClientError) as exc_info,
  85. ):
  86. await client.get_spool(42)
  87. assert exc_info.value.status_code == 422
  88. # ---------------------------------------------------------------------------
  89. # get_all_spools
  90. # ---------------------------------------------------------------------------
  91. class TestGetAllSpools:
  92. @pytest.mark.asyncio
  93. async def test_returns_list_without_archived_by_default(self, client):
  94. mock_http = AsyncMock()
  95. mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL]))
  96. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  97. result = await client.get_all_spools()
  98. assert result == [SAMPLE_SPOOL]
  99. mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params=None)
  100. @pytest.mark.asyncio
  101. async def test_passes_allow_archived_param(self, client):
  102. mock_http = AsyncMock()
  103. mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL]))
  104. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  105. await client.get_all_spools(allow_archived=True)
  106. mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params={"allow_archived": "true"})
  107. @pytest.mark.asyncio
  108. async def test_raises_unavailable_on_error(self, client):
  109. mock_http = AsyncMock()
  110. mock_http.get = AsyncMock(side_effect=Exception("connection error"))
  111. with (
  112. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  113. pytest.raises(SpoolmanUnavailableError),
  114. ):
  115. await client.get_all_spools()
  116. # ---------------------------------------------------------------------------
  117. # delete_spool
  118. # ---------------------------------------------------------------------------
  119. class TestDeleteSpool:
  120. @pytest.mark.asyncio
  121. async def test_returns_none_on_success(self, client):
  122. mock_http = AsyncMock()
  123. mock_http.request = AsyncMock(return_value=_make_response(None))
  124. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  125. result = await client.delete_spool(42)
  126. assert result is None
  127. mock_http.request.assert_called_once_with("DELETE", "http://localhost:7912/api/v1/spool/42", json=None)
  128. @pytest.mark.asyncio
  129. async def test_raises_unavailable_on_error(self, client):
  130. mock_http = AsyncMock()
  131. mock_http.request = AsyncMock(side_effect=Exception("server error"))
  132. with (
  133. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  134. pytest.raises(SpoolmanUnavailableError),
  135. ):
  136. await client.delete_spool(42)
  137. # ---------------------------------------------------------------------------
  138. # set_spool_archived
  139. # ---------------------------------------------------------------------------
  140. class TestSetSpoolArchived:
  141. @pytest.mark.asyncio
  142. async def test_archives_spool(self, client):
  143. archived_spool = {**SAMPLE_SPOOL, "archived": True}
  144. mock_http = AsyncMock()
  145. mock_http.request = AsyncMock(return_value=_make_response(archived_spool))
  146. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  147. result = await client.set_spool_archived(42, archived=True)
  148. assert result == archived_spool
  149. mock_http.request.assert_called_once_with(
  150. "PATCH",
  151. "http://localhost:7912/api/v1/spool/42",
  152. json={"archived": True},
  153. )
  154. @pytest.mark.asyncio
  155. async def test_restores_spool(self, client):
  156. restored_spool = {**SAMPLE_SPOOL, "archived": False}
  157. mock_http = AsyncMock()
  158. mock_http.request = AsyncMock(return_value=_make_response(restored_spool))
  159. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  160. result = await client.set_spool_archived(42, archived=False)
  161. assert result == restored_spool
  162. mock_http.request.assert_called_once_with(
  163. "PATCH",
  164. "http://localhost:7912/api/v1/spool/42",
  165. json={"archived": False},
  166. )
  167. @pytest.mark.asyncio
  168. async def test_raises_unavailable_on_error(self, client):
  169. mock_http = AsyncMock()
  170. mock_http.request = AsyncMock(side_effect=Exception("timeout"))
  171. with (
  172. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  173. pytest.raises(SpoolmanUnavailableError),
  174. ):
  175. await client.set_spool_archived(42, archived=True)
  176. # ---------------------------------------------------------------------------
  177. # update_spool_full
  178. # ---------------------------------------------------------------------------
  179. class TestUpdateSpoolFull:
  180. @pytest.mark.asyncio
  181. async def test_sends_only_provided_fields(self, client):
  182. mock_http = AsyncMock()
  183. mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
  184. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  185. await client.update_spool_full(42, remaining_weight=600.0, comment="note")
  186. call_json = mock_http.request.call_args.kwargs["json"]
  187. assert call_json == {"remaining_weight": 600.0, "comment": "note"}
  188. @pytest.mark.asyncio
  189. async def test_clear_location_sets_none(self, client):
  190. mock_http = AsyncMock()
  191. mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
  192. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  193. await client.update_spool_full(42, clear_location=True)
  194. call_json = mock_http.request.call_args.kwargs["json"]
  195. assert call_json == {"location": None}
  196. @pytest.mark.asyncio
  197. async def test_location_set_when_not_clearing(self, client):
  198. mock_http = AsyncMock()
  199. mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
  200. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  201. await client.update_spool_full(42, location="Shelf A")
  202. call_json = mock_http.request.call_args.kwargs["json"]
  203. assert call_json == {"location": "Shelf A"}
  204. @pytest.mark.asyncio
  205. async def test_empty_comment_sent_as_none(self, client):
  206. mock_http = AsyncMock()
  207. mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
  208. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  209. await client.update_spool_full(42, comment="")
  210. call_json = mock_http.request.call_args.kwargs["json"]
  211. assert call_json == {"comment": None}
  212. @pytest.mark.asyncio
  213. async def test_raises_unavailable_on_error(self, client):
  214. mock_http = AsyncMock()
  215. mock_http.request = AsyncMock(side_effect=Exception("network"))
  216. with (
  217. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  218. pytest.raises(SpoolmanUnavailableError),
  219. ):
  220. await client.update_spool_full(42, remaining_weight=500.0)
  221. # ---------------------------------------------------------------------------
  222. # find_or_create_vendor
  223. # ---------------------------------------------------------------------------
  224. class TestFindOrCreateVendor:
  225. @pytest.mark.asyncio
  226. async def test_returns_existing_vendor_id(self, client):
  227. with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])):
  228. result = await client.find_or_create_vendor("Bambu Lab")
  229. assert result == 3
  230. @pytest.mark.asyncio
  231. async def test_case_insensitive_match(self, client):
  232. with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])):
  233. result = await client.find_or_create_vendor("bambu lab")
  234. assert result == 3
  235. @pytest.mark.asyncio
  236. async def test_creates_vendor_when_not_found(self, client):
  237. new_vendor = {"id": 10, "name": "New Brand"}
  238. with (
  239. patch.object(client, "get_vendors", AsyncMock(return_value=[])),
  240. patch.object(client, "create_vendor", AsyncMock(return_value=new_vendor)) as mock_create,
  241. ):
  242. result = await client.find_or_create_vendor("New Brand")
  243. assert result == 10
  244. mock_create.assert_called_once_with("New Brand")
  245. @pytest.mark.asyncio
  246. async def test_raises_when_create_fails(self, client):
  247. with (
  248. patch.object(client, "get_vendors", AsyncMock(return_value=[])),
  249. patch.object(client, "create_vendor", AsyncMock(side_effect=SpoolmanUnavailableError("unreachable"))),
  250. pytest.raises(SpoolmanUnavailableError),
  251. ):
  252. await client.find_or_create_vendor("Ghost Brand")
  253. # ---------------------------------------------------------------------------
  254. # find_or_create_filament
  255. # ---------------------------------------------------------------------------
  256. class TestFindOrCreateFilament:
  257. @pytest.mark.asyncio
  258. async def test_returns_existing_filament_id(self, client):
  259. with (
  260. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  261. patch.object(client, "get_filaments", AsyncMock(return_value=[SAMPLE_FILAMENT])),
  262. ):
  263. result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000)
  264. assert result == 7
  265. @pytest.mark.asyncio
  266. async def test_color_name_does_not_trigger_filament_patch(self, client):
  267. """#1357: Spoolman 0.23.1 has no `color_name` field on Filament
  268. (verified against FilamentUpdateParameters schema). find_or_create_filament
  269. must NOT attempt to PATCH it — the route now persists the user's
  270. color_name to spool.extra.bambu_color_name instead. Any patch call
  271. from this layer would be a silent no-op (Spoolman ignores unknown
  272. keys) and was the original symptom of "edits never save".
  273. """
  274. existing = {**SAMPLE_FILAMENT}
  275. with (
  276. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  277. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  278. patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
  279. ):
  280. result = await client.find_or_create_filament(
  281. "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
  282. )
  283. assert result == 7
  284. mock_patch.assert_not_called()
  285. @pytest.mark.asyncio
  286. async def test_matches_filament_named_with_just_subtype(self, client):
  287. """#1357: AMS-sync auto-create saves the filament with name set to just
  288. ``tray.tray_sub_brands`` (e.g. ``"Glow"`` without the material prefix),
  289. but the user-driven edit path composes ``"<material> <subtype>"``
  290. (``"PLA Glow"``). Before this fix the literal `f_name == name` check
  291. failed to bridge the two shapes, so every edit fell through to
  292. ``create_filament`` and left a trail of duplicate filaments. Now the
  293. name match strips the material prefix on both sides, so the two
  294. shapes resolve to the same subtype key."""
  295. existing = {
  296. **SAMPLE_FILAMENT,
  297. "id": 11,
  298. "name": "Glow", # AMS-sync shape: just subtype
  299. "material": "PLA",
  300. "color_hex": "AAF3C6",
  301. "color_name": None,
  302. "vendor": {"id": 3, "name": "Amazon Basics"},
  303. }
  304. with (
  305. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  306. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  307. patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
  308. patch.object(client, "create_filament", AsyncMock()) as mock_create,
  309. ):
  310. result = await client.find_or_create_filament(
  311. "PLA", "Glow", "Amazon Basics", "AAF3C6", 1000, color_name="Bright Glow"
  312. )
  313. assert result == 11
  314. # color_name is no longer written via the filament — see #1357 — and
  315. # the function must not create a duplicate filament.
  316. mock_patch.assert_not_called()
  317. mock_create.assert_not_called()
  318. @pytest.mark.asyncio
  319. async def test_still_matches_filament_named_material_plus_subtype(self, client):
  320. """The composed-name shape (``"PLA Basic"`` matching a Spoolman filament
  321. also named ``"PLA Basic"``) must keep working — the normalisation strips
  322. the prefix on both sides, so the comparison is on the subtype part."""
  323. existing = {
  324. **SAMPLE_FILAMENT,
  325. "id": 7,
  326. "name": "PLA Basic",
  327. "material": "PLA",
  328. "color_hex": "FF0000",
  329. "color_name": "Sunset",
  330. }
  331. with (
  332. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  333. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  334. patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})),
  335. patch.object(client, "create_filament", AsyncMock()) as mock_create,
  336. ):
  337. result = await client.find_or_create_filament(
  338. "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunset"
  339. )
  340. assert result == 7
  341. mock_create.assert_not_called()
  342. @pytest.mark.asyncio
  343. async def test_name_match_does_not_cross_materials(self, client):
  344. """Sanity check: a filament with name=subtype must NOT match a request
  345. with a different material that happens to share the subtype string.
  346. material_match runs first and fails, so the iteration moves on and
  347. ``create_filament`` is called."""
  348. existing = {
  349. **SAMPLE_FILAMENT,
  350. "id": 7,
  351. "name": "Basic",
  352. "material": "PETG", # different material
  353. "color_hex": "FF0000",
  354. }
  355. new_filament = {"id": 99, "name": "PLA Basic"}
  356. with (
  357. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  358. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  359. patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
  360. ):
  361. result = await client.find_or_create_filament(
  362. "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunset"
  363. )
  364. assert result == 99
  365. mock_create.assert_called_once()
  366. @pytest.mark.asyncio
  367. async def test_creates_filament_when_no_match(self, client):
  368. new_filament = {"id": 99, "name": "PETG Pro"}
  369. with (
  370. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  371. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  372. patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
  373. ):
  374. result = await client.find_or_create_filament("PETG", "Pro", "Bambu Lab", "00FF00", 1000)
  375. assert result == 99
  376. # color_name is intentionally not forwarded to create_filament (#1357):
  377. # Spoolman has no such field on Filament, so passing it would be a
  378. # no-op. The route persists color_name to spool.extra.bambu_color_name
  379. # after this returns.
  380. mock_create.assert_called_once_with(
  381. name="PETG Pro",
  382. vendor_id=3,
  383. material="PETG",
  384. color_hex="00FF00",
  385. weight=1000.0,
  386. )
  387. @pytest.mark.asyncio
  388. async def test_no_brand_skips_vendor_lookup(self, client):
  389. filament_no_vendor = {
  390. **SAMPLE_FILAMENT,
  391. "vendor": None,
  392. "name": "PLA Basic",
  393. "color_hex": "FF0000",
  394. }
  395. with (
  396. patch.object(client, "get_filaments", AsyncMock(return_value=[filament_no_vendor])),
  397. ):
  398. result = await client.find_or_create_filament("PLA", "Basic", None, "FF0000", 1000)
  399. assert result == 7
  400. @pytest.mark.asyncio
  401. async def test_color_hex_normalised_to_uppercase(self, client):
  402. with (
  403. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=None)),
  404. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  405. patch.object(client, "create_filament", AsyncMock(return_value={"id": 5})) as mock_create,
  406. ):
  407. await client.find_or_create_filament("ABS", "", None, "ff0000", 750)
  408. mock_create.assert_called_once_with(
  409. name="ABS",
  410. vendor_id=None,
  411. material="ABS",
  412. color_hex="FF0000",
  413. weight=750.0,
  414. )
  415. @pytest.mark.asyncio
  416. async def test_raises_when_create_fails(self, client):
  417. with (
  418. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=1)),
  419. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  420. patch.object(client, "create_filament", AsyncMock(side_effect=SpoolmanUnavailableError("unreachable"))),
  421. pytest.raises(SpoolmanUnavailableError),
  422. ):
  423. await client.find_or_create_filament("TPU", "Flex", "Generic", "000000", 500)
  424. # ---------------------------------------------------------------------------
  425. # get_filaments / get_vendors / get_external_filaments error propagation (H11)
  426. # ---------------------------------------------------------------------------
  427. class TestGetFilamentsRaisesOnError:
  428. @pytest.mark.asyncio
  429. async def test_raises_unavailable_on_error(self, client):
  430. mock_http = AsyncMock()
  431. mock_http.get = AsyncMock(side_effect=Exception("timeout"))
  432. with (
  433. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  434. pytest.raises(SpoolmanUnavailableError),
  435. ):
  436. await client.get_filaments()
  437. class TestGetVendorsRaisesOnError:
  438. @pytest.mark.asyncio
  439. async def test_raises_unavailable_on_error(self, client):
  440. mock_http = AsyncMock()
  441. mock_http.get = AsyncMock(side_effect=Exception("timeout"))
  442. with (
  443. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  444. pytest.raises(SpoolmanUnavailableError),
  445. ):
  446. await client.get_vendors()
  447. class TestGetExternalFilamentsRaisesOnError:
  448. @pytest.mark.asyncio
  449. async def test_raises_unavailable_on_error(self, client):
  450. mock_http = AsyncMock()
  451. mock_http.get = AsyncMock(side_effect=Exception("timeout"))
  452. with (
  453. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  454. pytest.raises(SpoolmanUnavailableError),
  455. ):
  456. await client.get_external_filaments()