test_spoolman_inventory_methods.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  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()
  457. # ---------------------------------------------------------------------------
  458. # get_distinct_locations — shape normalisation (#1505 review BLOCKER 3)
  459. # ---------------------------------------------------------------------------
  460. class TestGetDistinctLocationsShape:
  461. @pytest.mark.asyncio
  462. async def test_passes_through_list_of_strings(self, client):
  463. with patch.object(client, "_get_with_retry", AsyncMock(return_value=["Drybox 1", "Shelf"])):
  464. result = await client.get_distinct_locations()
  465. assert result == ["Drybox 1", "Shelf"]
  466. @pytest.mark.asyncio
  467. async def test_extracts_name_from_list_of_dicts(self, client):
  468. with patch.object(
  469. client,
  470. "_get_with_retry",
  471. AsyncMock(return_value=[{"id": 1, "name": "Drybox 1"}, {"id": 2, "name": "Shelf"}]),
  472. ):
  473. result = await client.get_distinct_locations()
  474. assert result == ["Drybox 1", "Shelf"]
  475. @pytest.mark.asyncio
  476. async def test_drops_non_string_and_dict_without_name(self, client):
  477. with patch.object(
  478. client,
  479. "_get_with_retry",
  480. AsyncMock(return_value=[{"id": 1}, None, 42, "Shelf"]),
  481. ):
  482. result = await client.get_distinct_locations()
  483. assert result == ["Shelf"]
  484. @pytest.mark.asyncio
  485. async def test_returns_empty_list_on_non_list_payload(self, client):
  486. # A misconfigured proxy or auth-redirect can serve HTML; the old shape
  487. # would TypeError on iteration. We coerce to [].
  488. with patch.object(client, "_get_with_retry", AsyncMock(return_value={"error": "unauthorized"})):
  489. result = await client.get_distinct_locations()
  490. assert result == []
  491. # ---------------------------------------------------------------------------
  492. # rename_location — bulk endpoint + per-spool fallback (#1505 review BLOCKER 2)
  493. # ---------------------------------------------------------------------------
  494. class TestRenameLocationBulkAndFallback:
  495. @pytest.mark.asyncio
  496. async def test_bulk_endpoint_success_returns_zero(self, client):
  497. """Modern Spoolman PATCH /location/{name} succeeds — fallback not used."""
  498. mock_http = AsyncMock()
  499. mock_http.patch = AsyncMock(return_value=_make_response(None))
  500. with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
  501. result = await client.rename_location("Drybox 1", "Drybox 2")
  502. assert result == 0
  503. # Confirm the bulk path was used (no per-spool PATCH).
  504. mock_http.patch.assert_called_once()
  505. assert "/location/" in mock_http.patch.call_args.args[0]
  506. @pytest.mark.asyncio
  507. async def test_bulk_endpoint_404_falls_back_to_per_spool_patch(self, client):
  508. """Older Spoolman versions return 404 on the bulk endpoint — the
  509. fallback iterates every spool currently at the old name."""
  510. bulk_response = MagicMock()
  511. bulk_response.status_code = 404
  512. bulk_response.raise_for_status = MagicMock(
  513. side_effect=httpx.HTTPStatusError("404 Not Found", request=MagicMock(), response=MagicMock(status_code=404))
  514. )
  515. mock_http = AsyncMock()
  516. mock_http.patch = AsyncMock(return_value=bulk_response)
  517. spools_at_old = [
  518. {"id": 11, "location": "Drybox 1"},
  519. {"id": 12, "location": "Drybox 1"},
  520. {"id": 13, "location": "Shelf A"}, # different location — must be skipped
  521. ]
  522. patch_response = MagicMock()
  523. patch_response.status_code = 200
  524. patch_response.raise_for_status = MagicMock()
  525. patch_response.json.return_value = {"id": 0, "location": "Drybox 2"}
  526. with (
  527. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  528. patch.object(client, "get_all_spools", AsyncMock(return_value=spools_at_old)),
  529. patch.object(client, "_request_spool", AsyncMock(return_value=patch_response)) as request_spool_mock,
  530. ):
  531. result = await client.rename_location("Drybox 1", "Drybox 2")
  532. assert result == 2
  533. # Only the two matching spools should be PATCHed.
  534. assert request_spool_mock.await_count == 2
  535. called_ids = sorted(call.args[1] for call in request_spool_mock.await_args_list)
  536. assert called_ids == [11, 12]
  537. # And each call should set the new location string.
  538. for call in request_spool_mock.await_args_list:
  539. assert call.kwargs["json_body"] == {"location": "Drybox 2"}
  540. @pytest.mark.asyncio
  541. async def test_bulk_endpoint_405_also_falls_back(self, client):
  542. """Some Spoolman versions return 405 Method Not Allowed instead of 404
  543. when the bulk endpoint is missing — same fallback."""
  544. bulk_response = MagicMock()
  545. bulk_response.status_code = 405
  546. bulk_response.raise_for_status = MagicMock(
  547. side_effect=httpx.HTTPStatusError(
  548. "405 Method Not Allowed", request=MagicMock(), response=MagicMock(status_code=405)
  549. )
  550. )
  551. mock_http = AsyncMock()
  552. mock_http.patch = AsyncMock(return_value=bulk_response)
  553. with (
  554. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  555. patch.object(client, "get_all_spools", AsyncMock(return_value=[])),
  556. ):
  557. result = await client.rename_location("Drybox 1", "Drybox 2")
  558. # No spools at the old name → nothing to do, fallback returns 0.
  559. assert result == 0
  560. @pytest.mark.asyncio
  561. async def test_bulk_endpoint_non_404_5xx_propagates(self, client):
  562. """A genuine server error must NOT silently fall back."""
  563. bulk_response = MagicMock()
  564. bulk_response.status_code = 500
  565. bulk_response.raise_for_status = MagicMock(
  566. side_effect=httpx.HTTPStatusError("500", request=MagicMock(), response=MagicMock(status_code=500))
  567. )
  568. mock_http = AsyncMock()
  569. mock_http.patch = AsyncMock(return_value=bulk_response)
  570. with (
  571. patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
  572. pytest.raises(httpx.HTTPStatusError),
  573. ):
  574. await client.rename_location("Drybox 1", "Drybox 2")