test_spoolman_inventory_methods.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  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_creates_filament_when_no_match(self, client):
  267. new_filament = {"id": 99, "name": "PETG Pro"}
  268. with (
  269. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
  270. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  271. patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
  272. ):
  273. result = await client.find_or_create_filament("PETG", "Pro", "Bambu Lab", "00FF00", 1000)
  274. assert result == 99
  275. mock_create.assert_called_once_with(
  276. name="PETG Pro",
  277. vendor_id=3,
  278. material="PETG",
  279. color_hex="00FF00",
  280. color_name=None,
  281. weight=1000.0,
  282. )
  283. @pytest.mark.asyncio
  284. async def test_no_brand_skips_vendor_lookup(self, client):
  285. filament_no_vendor = {
  286. **SAMPLE_FILAMENT,
  287. "vendor": None,
  288. "name": "PLA Basic",
  289. "color_hex": "FF0000",
  290. }
  291. with (
  292. patch.object(client, "get_filaments", AsyncMock(return_value=[filament_no_vendor])),
  293. ):
  294. result = await client.find_or_create_filament("PLA", "Basic", None, "FF0000", 1000)
  295. assert result == 7
  296. @pytest.mark.asyncio
  297. async def test_color_hex_normalised_to_uppercase(self, client):
  298. with (
  299. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=None)),
  300. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  301. patch.object(client, "create_filament", AsyncMock(return_value={"id": 5})) as mock_create,
  302. ):
  303. await client.find_or_create_filament("ABS", "", None, "ff0000", 750)
  304. mock_create.assert_called_once_with(
  305. name="ABS",
  306. vendor_id=None,
  307. material="ABS",
  308. color_hex="FF0000",
  309. color_name=None,
  310. weight=750.0,
  311. )
  312. @pytest.mark.asyncio
  313. async def test_raises_when_create_fails(self, client):
  314. with (
  315. patch.object(client, "find_or_create_vendor", AsyncMock(return_value=1)),
  316. patch.object(client, "get_filaments", AsyncMock(return_value=[])),
  317. patch.object(client, "create_filament", AsyncMock(side_effect=SpoolmanUnavailableError("unreachable"))),
  318. pytest.raises(SpoolmanUnavailableError),
  319. ):
  320. await client.find_or_create_filament("TPU", "Flex", "Generic", "000000", 500)
  321. # ---------------------------------------------------------------------------
  322. # get_filaments / get_vendors / get_external_filaments error propagation (H11)
  323. # ---------------------------------------------------------------------------
  324. class TestGetFilamentsRaisesOnError:
  325. @pytest.mark.asyncio
  326. async def test_raises_unavailable_on_error(self, client):
  327. mock_http = AsyncMock()
  328. mock_http.get = AsyncMock(side_effect=Exception("timeout"))
  329. with (
  330. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  331. pytest.raises(SpoolmanUnavailableError),
  332. ):
  333. await client.get_filaments()
  334. class TestGetVendorsRaisesOnError:
  335. @pytest.mark.asyncio
  336. async def test_raises_unavailable_on_error(self, client):
  337. mock_http = AsyncMock()
  338. mock_http.get = AsyncMock(side_effect=Exception("timeout"))
  339. with (
  340. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  341. pytest.raises(SpoolmanUnavailableError),
  342. ):
  343. await client.get_vendors()
  344. class TestGetExternalFilamentsRaisesOnError:
  345. @pytest.mark.asyncio
  346. async def test_raises_unavailable_on_error(self, client):
  347. mock_http = AsyncMock()
  348. mock_http.get = AsyncMock(side_effect=Exception("timeout"))
  349. with (
  350. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  351. pytest.raises(SpoolmanUnavailableError),
  352. ):
  353. await client.get_external_filaments()