test_spoolman_inventory_methods.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  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_patches_color_name_on_existing_filament_when_changed(self, client):
  267. """#1319: color_name is not part of the match key, so when a caller
  268. updates a spool with a new color_name and the material/name/color/vendor
  269. still match an existing filament, the existing filament's color_name
  270. must be patched — otherwise the user's edit is silently dropped."""
  271. existing = {**SAMPLE_FILAMENT, "color_name": None}
  272. with (
  273. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  274. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  275. patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
  276. ):
  277. result = await client.find_or_create_filament(
  278. "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
  279. )
  280. assert result == 7
  281. mock_patch.assert_called_once_with(7, {"color_name": "Sunny Yellow"})
  282. @pytest.mark.asyncio
  283. async def test_does_not_patch_when_color_name_unchanged(self, client):
  284. existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
  285. with (
  286. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  287. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  288. patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
  289. ):
  290. result = await client.find_or_create_filament(
  291. "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
  292. )
  293. assert result == 7
  294. mock_patch.assert_not_called()
  295. @pytest.mark.asyncio
  296. async def test_does_not_patch_when_color_name_empty(self, client):
  297. """An empty/None color_name should not clobber an existing value."""
  298. existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
  299. with (
  300. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  301. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  302. patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
  303. ):
  304. result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name=None)
  305. assert result == 7
  306. mock_patch.assert_not_called()
  307. @pytest.mark.asyncio
  308. async def test_clears_color_name_when_empty_string_passed(self, client):
  309. """#1319 follow-up: empty string means "explicit clear" — the route
  310. layer translates a wire-level null into "" so the user can blank the
  311. field on a previously-set spool."""
  312. existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
  313. with (
  314. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  315. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  316. patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
  317. ):
  318. result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="")
  319. assert result == 7
  320. mock_patch.assert_called_once_with(7, {"color_name": None})
  321. @pytest.mark.asyncio
  322. async def test_patch_failure_does_not_block_match(self, client):
  323. """A patch_filament failure must not prevent returning the matched id —
  324. save should still link the spool to the correct filament."""
  325. existing = {**SAMPLE_FILAMENT, "color_name": None}
  326. with (
  327. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  328. patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
  329. patch.object(client, "patch_filament", AsyncMock(side_effect=SpoolmanUnavailableError("boom"))),
  330. ):
  331. result = await client.find_or_create_filament(
  332. "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
  333. )
  334. assert result == 7
  335. @pytest.mark.asyncio
  336. async def test_creates_filament_when_no_match(self, client):
  337. new_filament = {"id": 99, "name": "PETG Pro"}
  338. with (
  339. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  340. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  341. patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
  342. ):
  343. result = await client.find_or_create_filament("PETG", "Pro", "Bambu Lab", "00FF00", 1000)
  344. assert result == 99
  345. mock_create.assert_called_once_with(
  346. name="PETG Pro",
  347. vendor_id=3,
  348. material="PETG",
  349. color_hex="00FF00",
  350. color_name=None,
  351. weight=1000.0,
  352. )
  353. @pytest.mark.asyncio
  354. async def test_no_brand_skips_vendor_lookup(self, client):
  355. filament_no_vendor = {
  356. **SAMPLE_FILAMENT,
  357. "vendor": None,
  358. "name": "PLA Basic",
  359. "color_hex": "FF0000",
  360. }
  361. with (
  362. patch.object(client, "get_filaments", AsyncMock(return_value=[filament_no_vendor])),
  363. ):
  364. result = await client.find_or_create_filament("PLA", "Basic", None, "FF0000", 1000)
  365. assert result == 7
  366. @pytest.mark.asyncio
  367. async def test_color_hex_normalised_to_uppercase(self, client):
  368. with (
  369. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=None)),
  370. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  371. patch.object(client, "create_filament", AsyncMock(return_value={"id": 5})) as mock_create,
  372. ):
  373. await client.find_or_create_filament("ABS", "", None, "ff0000", 750)
  374. mock_create.assert_called_once_with(
  375. name="ABS",
  376. vendor_id=None,
  377. material="ABS",
  378. color_hex="FF0000",
  379. color_name=None,
  380. weight=750.0,
  381. )
  382. @pytest.mark.asyncio
  383. async def test_raises_when_create_fails(self, client):
  384. with (
  385. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=1)),
  386. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  387. patch.object(client, "create_filament", AsyncMock(side_effect=SpoolmanUnavailableError("unreachable"))),
  388. pytest.raises(SpoolmanUnavailableError),
  389. ):
  390. await client.find_or_create_filament("TPU", "Flex", "Generic", "000000", 500)
  391. # ---------------------------------------------------------------------------
  392. # get_filaments / get_vendors / get_external_filaments error propagation (H11)
  393. # ---------------------------------------------------------------------------
  394. class TestGetFilamentsRaisesOnError:
  395. @pytest.mark.asyncio
  396. async def test_raises_unavailable_on_error(self, client):
  397. mock_http = AsyncMock()
  398. mock_http.get = AsyncMock(side_effect=Exception("timeout"))
  399. with (
  400. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  401. pytest.raises(SpoolmanUnavailableError),
  402. ):
  403. await client.get_filaments()
  404. class TestGetVendorsRaisesOnError:
  405. @pytest.mark.asyncio
  406. async def test_raises_unavailable_on_error(self, client):
  407. mock_http = AsyncMock()
  408. mock_http.get = AsyncMock(side_effect=Exception("timeout"))
  409. with (
  410. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  411. pytest.raises(SpoolmanUnavailableError),
  412. ):
  413. await client.get_vendors()
  414. class TestGetExternalFilamentsRaisesOnError:
  415. @pytest.mark.asyncio
  416. async def test_raises_unavailable_on_error(self, client):
  417. mock_http = AsyncMock()
  418. mock_http.get = AsyncMock(side_effect=Exception("timeout"))
  419. with (
  420. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  421. pytest.raises(SpoolmanUnavailableError),
  422. ):
  423. await client.get_external_filaments()