test_spoolman_inventory_api.py 105 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659
  1. """Integration tests for the Spoolman inventory proxy endpoints.
  2. These tests verify that /api/v1/spoolman/inventory/spools/* correctly
  3. translates between Spoolman's data model and Bambuddy's InventorySpool format.
  4. """
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from fastapi import HTTPException
  8. from httpx import AsyncClient
  9. # ---------------------------------------------------------------------------
  10. # Shared fixtures
  11. # ---------------------------------------------------------------------------
  12. SAMPLE_SPOOLMAN_SPOOL = {
  13. "id": 42,
  14. "filament": {
  15. "id": 7,
  16. "name": "PLA Basic",
  17. "material": "PLA",
  18. "color_hex": "FF0000",
  19. "weight": 1000,
  20. "vendor": {"id": 3, "name": "Bambu Lab"},
  21. },
  22. "remaining_weight": 750.0,
  23. "used_weight": 250.0,
  24. "location": "Printer1 - AMS A1",
  25. "comment": "test note",
  26. "first_used": "2024-01-01T00:00:00+00:00",
  27. "last_used": "2024-02-01T00:00:00+00:00",
  28. "registered": "2024-01-01T00:00:00+00:00",
  29. "archived": False,
  30. "price": None,
  31. "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'},
  32. }
  33. @pytest.fixture
  34. async def spoolman_settings(db_session):
  35. """Create Spoolman settings in the database (enabled with URL)."""
  36. from backend.app.models.settings import Settings
  37. enabled_setting = Settings(key="spoolman_enabled", value="true")
  38. url_setting = Settings(key="spoolman_url", value="http://localhost:7912")
  39. db_session.add(enabled_setting)
  40. db_session.add(url_setting)
  41. await db_session.commit()
  42. return {"enabled": enabled_setting, "url": url_setting}
  43. @pytest.fixture
  44. def mock_spoolman_client():
  45. """Mock the Spoolman client with a sample spool."""
  46. mock_client = MagicMock()
  47. mock_client.base_url = "http://localhost:7912"
  48. mock_client.health_check = AsyncMock(return_value=True)
  49. mock_client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOLMAN_SPOOL])
  50. mock_client.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  51. mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  52. mock_client.delete_spool = AsyncMock(return_value=True)
  53. mock_client.set_spool_archived = AsyncMock(
  54. side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
  55. )
  56. mock_client.reset_spool_usage = AsyncMock(return_value={**SAMPLE_SPOOLMAN_SPOOL, "used_weight": 0})
  57. mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  58. mock_client.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  59. mock_client.find_or_create_filament = AsyncMock(return_value=7)
  60. mock_client.ensure_extra_field = AsyncMock(return_value=True)
  61. with (
  62. patch(
  63. "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
  64. AsyncMock(return_value=mock_client),
  65. ),
  66. patch(
  67. "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
  68. AsyncMock(return_value=mock_client),
  69. ),
  70. ):
  71. yield mock_client
  72. class TestSpoolmanInventoryMapping:
  73. """Tests for the Spoolman → InventorySpool data mapping."""
  74. @pytest.mark.asyncio
  75. @pytest.mark.integration
  76. async def test_list_spools_returns_inventory_format(
  77. self,
  78. async_client: AsyncClient,
  79. spoolman_settings,
  80. mock_spoolman_client,
  81. ):
  82. """GET /spoolman/inventory/spools returns spools in InventorySpool format."""
  83. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  84. assert response.status_code == 200
  85. spools = response.json()
  86. assert isinstance(spools, list)
  87. assert len(spools) == 1
  88. spool = spools[0]
  89. assert spool["id"] == 42
  90. assert spool["material"] == "PLA"
  91. assert spool["subtype"] == "Basic"
  92. assert spool["brand"] == "Bambu Lab"
  93. assert spool["label_weight"] == 1000
  94. assert spool["weight_used"] == 250.0
  95. assert spool["note"] == "test note"
  96. assert spool["data_origin"] == "spoolman"
  97. assert spool["tag_type"] == "spoolman"
  98. # RRGGBB + FF alpha
  99. assert spool["rgba"] == "FF0000FF"
  100. # Spoolman location mapped to storage_location
  101. assert spool["storage_location"] == "Printer1 - AMS A1"
  102. # RFID tag: 32-char → tray_uuid
  103. assert spool["tray_uuid"] == "AABBCCDDEEFF0011AABBCCDDEEFF0011"
  104. assert spool["tag_uid"] is None
  105. @pytest.mark.asyncio
  106. @pytest.mark.integration
  107. async def test_get_single_spool(
  108. self,
  109. async_client: AsyncClient,
  110. spoolman_settings,
  111. mock_spoolman_client,
  112. ):
  113. """GET /spoolman/inventory/spools/{id} returns a single spool."""
  114. response = await async_client.get("/api/v1/spoolman/inventory/spools/42")
  115. assert response.status_code == 200
  116. spool = response.json()
  117. assert spool["id"] == 42
  118. assert spool["material"] == "PLA"
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_list_includes_archived_when_requested(
  122. self,
  123. async_client: AsyncClient,
  124. spoolman_settings,
  125. mock_spoolman_client,
  126. ):
  127. """GET /spoolman/inventory/spools?include_archived=true calls Spoolman with allow_archived."""
  128. await async_client.get("/api/v1/spoolman/inventory/spools?include_archived=true")
  129. mock_spoolman_client.get_all_spools.assert_called_once_with(allow_archived=True)
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_archived_spool_has_archived_at(
  133. self,
  134. async_client: AsyncClient,
  135. spoolman_settings,
  136. mock_spoolman_client,
  137. ):
  138. """An archived Spoolman spool maps to archived_at != None."""
  139. archived_spool = {
  140. **SAMPLE_SPOOLMAN_SPOOL,
  141. "archived": True,
  142. }
  143. mock_spoolman_client.get_all_spools.return_value = [archived_spool]
  144. response = await async_client.get("/api/v1/spoolman/inventory/spools?include_archived=true")
  145. spool = response.json()[0]
  146. assert spool["archived_at"] is not None
  147. @pytest.mark.asyncio
  148. @pytest.mark.integration
  149. async def test_malformed_spool_skipped_in_list(
  150. self,
  151. async_client: AsyncClient,
  152. spoolman_settings,
  153. mock_spoolman_client,
  154. ):
  155. """A spool with an invalid id (e.g. 0) is silently skipped; others still appear."""
  156. bad_spool = {**SAMPLE_SPOOLMAN_SPOOL, "id": 0}
  157. mock_spoolman_client.get_all_spools.return_value = [bad_spool, SAMPLE_SPOOLMAN_SPOOL]
  158. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  159. assert response.status_code == 200
  160. spools = response.json()
  161. # bad_spool is dropped; the valid one survives
  162. assert len(spools) == 1
  163. assert spools[0]["id"] == 42
  164. @pytest.mark.asyncio
  165. @pytest.mark.integration
  166. async def test_list_spools_returns_503_when_spoolman_unavailable(
  167. self,
  168. async_client: AsyncClient,
  169. spoolman_settings,
  170. mock_spoolman_client,
  171. ):
  172. """GET /spoolman/inventory/spools returns 503 when Spoolman is unreachable (H10)."""
  173. from backend.app.services.spoolman import SpoolmanUnavailableError
  174. mock_spoolman_client.get_all_spools.side_effect = SpoolmanUnavailableError("down")
  175. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  176. assert response.status_code == 503
  177. @pytest.mark.asyncio
  178. @pytest.mark.integration
  179. async def test_tag_uid_16char_maps_correctly(
  180. self,
  181. async_client: AsyncClient,
  182. spoolman_settings,
  183. mock_spoolman_client,
  184. ):
  185. """A 16-char tag maps to tag_uid, not tray_uuid."""
  186. spool_with_short_tag = {
  187. **SAMPLE_SPOOLMAN_SPOOL,
  188. "extra": {"tag": '"AABBCCDDEEFF0011"'},
  189. }
  190. mock_spoolman_client.get_all_spools.return_value = [spool_with_short_tag]
  191. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  192. spool = response.json()[0]
  193. assert spool["tag_uid"] == "AABBCCDDEEFF0011"
  194. assert spool["tray_uuid"] is None
  195. class TestSpoolmanInventoryCRUD:
  196. """Tests for create, update, delete, archive, restore operations."""
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_not_enabled_returns_400(self, async_client: AsyncClient):
  200. """All endpoints return 400 when Spoolman is not enabled."""
  201. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  202. assert response.status_code == 400
  203. assert "not enabled" in response.json()["detail"].lower()
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_create_spool(
  207. self,
  208. async_client: AsyncClient,
  209. spoolman_settings,
  210. mock_spoolman_client,
  211. ):
  212. """POST /spoolman/inventory/spools creates a spool via Spoolman."""
  213. payload = {
  214. "material": "PLA",
  215. "subtype": "Basic",
  216. "brand": "Bambu Lab",
  217. "rgba": "FF0000FF",
  218. "label_weight": 1000,
  219. "weight_used": 0,
  220. }
  221. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  222. assert response.status_code == 200
  223. mock_spoolman_client.find_or_create_filament.assert_called_once()
  224. mock_spoolman_client.create_spool.assert_called_once()
  225. data = response.json()
  226. assert data["material"] == "PLA"
  227. @pytest.mark.asyncio
  228. @pytest.mark.integration
  229. async def test_bulk_create_spools(
  230. self,
  231. async_client: AsyncClient,
  232. spoolman_settings,
  233. mock_spoolman_client,
  234. ):
  235. """POST /spoolman/inventory/spools/bulk creates multiple spools."""
  236. payload = {
  237. "spool": {"material": "PETG", "label_weight": 1000, "weight_used": 0},
  238. "quantity": 3,
  239. }
  240. response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  241. assert response.status_code == 200
  242. assert mock_spoolman_client.create_spool.call_count == 3
  243. @pytest.mark.asyncio
  244. @pytest.mark.integration
  245. async def test_bulk_create_quantity_out_of_range_returns_422(
  246. self,
  247. async_client: AsyncClient,
  248. spoolman_settings,
  249. mock_spoolman_client,
  250. ):
  251. """Bulk create quantity outside 1-50 is rejected with 422 (not silently clamped)."""
  252. payload = {
  253. "spool": {"material": "ABS", "label_weight": 1000, "weight_used": 0},
  254. "quantity": 999,
  255. }
  256. response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  257. assert response.status_code == 422
  258. @pytest.mark.asyncio
  259. @pytest.mark.integration
  260. async def test_bulk_create_quantity_zero_returns_422(
  261. self,
  262. async_client: AsyncClient,
  263. spoolman_settings,
  264. mock_spoolman_client,
  265. ):
  266. """Bulk create quantity of 0 is rejected with 422."""
  267. payload = {
  268. "spool": {"material": "ABS", "label_weight": 1000, "weight_used": 0},
  269. "quantity": 0,
  270. }
  271. response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  272. assert response.status_code == 422
  273. @pytest.mark.asyncio
  274. @pytest.mark.integration
  275. async def test_update_spool(
  276. self,
  277. async_client: AsyncClient,
  278. spoolman_settings,
  279. mock_spoolman_client,
  280. ):
  281. """PATCH /spoolman/inventory/spools/{id} updates a spool."""
  282. payload = {"note": "updated note", "weight_used": 100.0}
  283. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  284. assert response.status_code == 200
  285. mock_spoolman_client.update_spool_full.assert_called_once()
  286. @pytest.mark.asyncio
  287. @pytest.mark.integration
  288. async def test_update_with_explicit_null_color_name_clears_extra(
  289. self,
  290. async_client: AsyncClient,
  291. spoolman_settings,
  292. mock_spoolman_client,
  293. ):
  294. """#1357: explicit color_name=null means "clear". The route writes a
  295. JSON-encoded empty string to spool.extra.bambu_color_name so the read
  296. path falls back to the synth value next time."""
  297. payload = {"color_name": None}
  298. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  299. assert response.status_code == 200
  300. mock_spoolman_client.ensure_extra_field.assert_any_call("bambu_color_name")
  301. mock_spoolman_client.merge_spool_extra.assert_called_once()
  302. _, kwargs = mock_spoolman_client.merge_spool_extra.call_args
  303. # First positional arg is spool_id; second is the extra-dict patch.
  304. args = mock_spoolman_client.merge_spool_extra.call_args.args
  305. extra_patch = args[1] if len(args) > 1 else kwargs.get("new_fields", {})
  306. import json as _json
  307. assert _json.loads(extra_patch["bambu_color_name"]) == ""
  308. @pytest.mark.asyncio
  309. @pytest.mark.integration
  310. async def test_update_without_color_name_skips_extra_write(
  311. self,
  312. async_client: AsyncClient,
  313. spoolman_settings,
  314. mock_spoolman_client,
  315. ):
  316. """#1357: when color_name is omitted from the PATCH body the extra
  317. write is skipped entirely — no merge_spool_extra call, no ensure_extra
  318. call for bambu_color_name. Only fields the request explicitly set go
  319. through the extra round-trip."""
  320. payload = {"note": "only updating note"}
  321. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  322. assert response.status_code == 200
  323. # No call should target bambu_color_name when color_name wasn't in the body.
  324. color_name_calls = [
  325. c
  326. for c in mock_spoolman_client.ensure_extra_field.call_args_list
  327. if c.args and c.args[0] == "bambu_color_name"
  328. ]
  329. assert color_name_calls == []
  330. @pytest.mark.asyncio
  331. @pytest.mark.integration
  332. async def test_update_spool_not_found(
  333. self,
  334. async_client: AsyncClient,
  335. spoolman_settings,
  336. mock_spoolman_client,
  337. ):
  338. """PATCH returns 404 when Spoolman spool does not exist."""
  339. from backend.app.services.spoolman import SpoolmanNotFoundError
  340. mock_spoolman_client.get_spool.side_effect = SpoolmanNotFoundError("spool not found")
  341. response = await async_client.patch("/api/v1/spoolman/inventory/spools/999", json={"note": "x"})
  342. assert response.status_code == 404
  343. @pytest.mark.asyncio
  344. @pytest.mark.integration
  345. async def test_delete_spool(
  346. self,
  347. async_client: AsyncClient,
  348. spoolman_settings,
  349. mock_spoolman_client,
  350. ):
  351. """DELETE /spoolman/inventory/spools/{id} deletes a spool."""
  352. response = await async_client.delete("/api/v1/spoolman/inventory/spools/42")
  353. assert response.status_code == 200
  354. assert response.json()["status"] == "deleted"
  355. mock_spoolman_client.delete_spool.assert_called_once_with(42)
  356. @pytest.mark.asyncio
  357. @pytest.mark.integration
  358. async def test_delete_spool_failure(
  359. self,
  360. async_client: AsyncClient,
  361. spoolman_settings,
  362. mock_spoolman_client,
  363. ):
  364. """DELETE returns 503 when Spoolman is unreachable."""
  365. from backend.app.services.spoolman import SpoolmanUnavailableError
  366. mock_spoolman_client.delete_spool.side_effect = SpoolmanUnavailableError("unreachable")
  367. response = await async_client.delete("/api/v1/spoolman/inventory/spools/42")
  368. assert response.status_code == 503
  369. @pytest.mark.asyncio
  370. @pytest.mark.integration
  371. async def test_delete_spool_not_found(
  372. self,
  373. async_client: AsyncClient,
  374. spoolman_settings,
  375. mock_spoolman_client,
  376. ):
  377. """DELETE returns 404 when Spoolman reports the spool does not exist."""
  378. from backend.app.services.spoolman import SpoolmanNotFoundError
  379. mock_spoolman_client.delete_spool.side_effect = SpoolmanNotFoundError("gone")
  380. response = await async_client.delete("/api/v1/spoolman/inventory/spools/42")
  381. assert response.status_code == 404
  382. @pytest.mark.asyncio
  383. @pytest.mark.integration
  384. async def test_archive_spool_not_found(
  385. self,
  386. async_client: AsyncClient,
  387. spoolman_settings,
  388. mock_spoolman_client,
  389. ):
  390. """POST /archive returns 404 when Spoolman reports the spool does not exist."""
  391. from backend.app.services.spoolman import SpoolmanNotFoundError
  392. mock_spoolman_client.set_spool_archived.side_effect = SpoolmanNotFoundError("gone")
  393. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
  394. assert response.status_code == 404
  395. @pytest.mark.asyncio
  396. @pytest.mark.integration
  397. async def test_restore_spool_not_found(
  398. self,
  399. async_client: AsyncClient,
  400. spoolman_settings,
  401. mock_spoolman_client,
  402. ):
  403. """POST /restore returns 404 when Spoolman reports the spool does not exist."""
  404. from backend.app.services.spoolman import SpoolmanNotFoundError
  405. mock_spoolman_client.set_spool_archived.side_effect = SpoolmanNotFoundError("gone")
  406. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/restore")
  407. assert response.status_code == 404
  408. @pytest.mark.asyncio
  409. @pytest.mark.integration
  410. async def test_archive_spool(
  411. self,
  412. async_client: AsyncClient,
  413. spoolman_settings,
  414. mock_spoolman_client,
  415. ):
  416. """POST /spoolman/inventory/spools/{id}/archive archives a spool."""
  417. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
  418. assert response.status_code == 200
  419. mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=True)
  420. @pytest.mark.asyncio
  421. @pytest.mark.integration
  422. async def test_restore_spool(
  423. self,
  424. async_client: AsyncClient,
  425. spoolman_settings,
  426. mock_spoolman_client,
  427. ):
  428. """POST /spoolman/inventory/spools/{id}/restore restores an archived spool."""
  429. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/restore")
  430. assert response.status_code == 200
  431. mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=False)
  432. @pytest.mark.asyncio
  433. @pytest.mark.integration
  434. async def test_reset_spool_usage(
  435. self,
  436. async_client: AsyncClient,
  437. spoolman_settings,
  438. mock_spoolman_client,
  439. ):
  440. """POST /spoolman/inventory/spools/{id}/reset-usage zeroes used_weight in Spoolman.
  441. Parity with internal mode (#1390): the InventorySpool response
  442. carries `weight_used = label - remaining` and
  443. `weight_used_baseline = weight_used - real_used_weight`, so the
  444. displayed consumed counter (weight_used - baseline) reads 0
  445. while remaining (= label - weight_used) preserves Spoolman's
  446. independent remaining_weight field.
  447. """
  448. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/reset-usage")
  449. assert response.status_code == 200
  450. body = response.json()
  451. # Sample spool: label=1000, remaining=750, used_weight=0 after Spoolman reset.
  452. assert body["weight_used"] == 250.0, "synthetic weight_used = label - remaining"
  453. assert body["weight_used_baseline"] == 250.0, "baseline absorbs the reset"
  454. assert body["weight_used"] - body["weight_used_baseline"] == 0, "displayed consumed = 0"
  455. assert body["label_weight"] - body["weight_used"] == 750, "remaining unchanged"
  456. mock_spoolman_client.reset_spool_usage.assert_called_once_with(42)
  457. @pytest.mark.asyncio
  458. @pytest.mark.integration
  459. async def test_bulk_reset_spool_usage(
  460. self,
  461. async_client: AsyncClient,
  462. spoolman_settings,
  463. mock_spoolman_client,
  464. ):
  465. """Bulk endpoint resets each listed spool and returns the count."""
  466. response = await async_client.post(
  467. "/api/v1/spoolman/inventory/spools/reset-usage-bulk",
  468. json={"spool_ids": [1, 2, 3]},
  469. )
  470. assert response.status_code == 200
  471. assert response.json() == {"reset": 3}
  472. assert mock_spoolman_client.reset_spool_usage.call_count == 3
  473. @pytest.mark.asyncio
  474. @pytest.mark.integration
  475. async def test_bulk_reset_rejects_empty_list(
  476. self,
  477. async_client: AsyncClient,
  478. spoolman_settings,
  479. mock_spoolman_client,
  480. ):
  481. """Empty list must be rejected — guards against accidental wildcard wipes."""
  482. response = await async_client.post(
  483. "/api/v1/spoolman/inventory/spools/reset-usage-bulk",
  484. json={"spool_ids": []},
  485. )
  486. assert response.status_code == 400
  487. mock_spoolman_client.reset_spool_usage.assert_not_called()
  488. @pytest.mark.asyncio
  489. @pytest.mark.integration
  490. async def test_sync_weight(
  491. self,
  492. async_client: AsyncClient,
  493. spoolman_settings,
  494. mock_spoolman_client,
  495. ):
  496. """PATCH /spoolman/inventory/spools/{id}/weight updates remaining weight."""
  497. payload = {"weight_grams": 850.0}
  498. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json=payload)
  499. assert response.status_code == 200
  500. result = response.json()
  501. assert result["status"] == "ok"
  502. # remaining = 850 - 250 core = 600; weight_used = 1000 - 600 = 400
  503. assert result["weight_used"] == 400.0
  504. mock_spoolman_client.update_spool_full.assert_called_once_with(spool_id=42, remaining_weight=600.0)
  505. @pytest.mark.asyncio
  506. @pytest.mark.integration
  507. async def test_update_spool_returns_404_on_not_found(
  508. self,
  509. async_client: AsyncClient,
  510. spoolman_settings,
  511. mock_spoolman_client,
  512. ):
  513. """PATCH returns 404 when update_spool_full raises SpoolmanNotFoundError (I2)."""
  514. from backend.app.services.spoolman import SpoolmanNotFoundError
  515. mock_spoolman_client.update_spool_full.side_effect = SpoolmanNotFoundError("gone")
  516. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "x"})
  517. assert response.status_code == 404
  518. @pytest.mark.asyncio
  519. @pytest.mark.integration
  520. async def test_update_spool_returns_503_on_unavailable(
  521. self,
  522. async_client: AsyncClient,
  523. spoolman_settings,
  524. mock_spoolman_client,
  525. ):
  526. """PATCH returns 503 when update_spool_full raises SpoolmanUnavailableError (I2)."""
  527. from backend.app.services.spoolman import SpoolmanUnavailableError
  528. mock_spoolman_client.update_spool_full.side_effect = SpoolmanUnavailableError("down")
  529. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "x"})
  530. assert response.status_code == 503
  531. @pytest.mark.asyncio
  532. @pytest.mark.integration
  533. async def test_sync_weight_returns_404_on_not_found(
  534. self,
  535. async_client: AsyncClient,
  536. spoolman_settings,
  537. mock_spoolman_client,
  538. ):
  539. """PATCH /weight returns 404 when update_spool_full raises SpoolmanNotFoundError (I2)."""
  540. from backend.app.services.spoolman import SpoolmanNotFoundError
  541. mock_spoolman_client.update_spool_full.side_effect = SpoolmanNotFoundError("gone")
  542. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 500.0})
  543. assert response.status_code == 404
  544. @pytest.mark.asyncio
  545. @pytest.mark.integration
  546. async def test_sync_weight_returns_503_on_unavailable(
  547. self,
  548. async_client: AsyncClient,
  549. spoolman_settings,
  550. mock_spoolman_client,
  551. ):
  552. """PATCH /weight returns 503 when update_spool_full raises SpoolmanUnavailableError (I2)."""
  553. from backend.app.services.spoolman import SpoolmanUnavailableError
  554. mock_spoolman_client.update_spool_full.side_effect = SpoolmanUnavailableError("down")
  555. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 500.0})
  556. assert response.status_code == 503
  557. class TestSpoolmanInventorySlicerFilament:
  558. """slicer_filament persistence via Spoolman extra dict.
  559. Spoolman has no native slicer_filament field — Bambuddy persists the
  560. BambuStudio preset under bambu_slicer_filament[_name] keys in the
  561. spool's extra dict and unwraps them in _map_spoolman_spool. Without
  562. this round-trip the user's slicer-preset selection on the spool form
  563. is silently dropped (#1114).
  564. """
  565. @pytest.mark.asyncio
  566. @pytest.mark.integration
  567. async def test_update_persists_slicer_filament_to_extra(
  568. self,
  569. async_client: AsyncClient,
  570. spoolman_settings,
  571. mock_spoolman_client,
  572. ):
  573. """PATCH with slicer_filament writes bambu_slicer_filament to extra.
  574. Spoolman's PATCH MERGES extra keys, so we send via merge_spool_extra
  575. not update_spool_full. Values are JSON-encoded strings.
  576. """
  577. import json as _json
  578. mock_spoolman_client.ensure_extra_field = AsyncMock(return_value=True)
  579. response = await async_client.patch(
  580. "/api/v1/spoolman/inventory/spools/42",
  581. json={
  582. "slicer_filament": "PFUSf543b298f8ea66",
  583. "slicer_filament_name": "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)",
  584. },
  585. )
  586. assert response.status_code == 200
  587. # Field registration is idempotent — must be called for each key
  588. ensure_calls = [c.args[0] for c in mock_spoolman_client.ensure_extra_field.call_args_list]
  589. assert "bambu_slicer_filament" in ensure_calls
  590. assert "bambu_slicer_filament_name" in ensure_calls
  591. # Values must be JSON-encoded so read-side can json.loads + .strip('"')
  592. mock_spoolman_client.merge_spool_extra.assert_called_once_with(
  593. 42,
  594. {
  595. "bambu_slicer_filament": _json.dumps("PFUSf543b298f8ea66"),
  596. "bambu_slicer_filament_name": _json.dumps("Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"),
  597. },
  598. )
  599. @pytest.mark.asyncio
  600. @pytest.mark.integration
  601. async def test_update_without_slicer_filament_skips_merge(
  602. self,
  603. async_client: AsyncClient,
  604. spoolman_settings,
  605. mock_spoolman_client,
  606. ):
  607. """PATCH without slicer_filament fields must not call merge_spool_extra.
  608. Avoids overwriting an existing preset with empty/null when the user
  609. just changed an unrelated field (e.g. note, weight).
  610. """
  611. response = await async_client.patch(
  612. "/api/v1/spoolman/inventory/spools/42",
  613. json={"note": "just changing the note"},
  614. )
  615. assert response.status_code == 200
  616. mock_spoolman_client.merge_spool_extra.assert_not_called()
  617. @pytest.mark.asyncio
  618. @pytest.mark.integration
  619. async def test_update_clears_slicer_filament_with_empty_string(
  620. self,
  621. async_client: AsyncClient,
  622. spoolman_settings,
  623. mock_spoolman_client,
  624. ):
  625. """Empty-string slicer_filament writes the JSON-encoded "" sentinel.
  626. The read-side strip('"') resolves it to an empty string and falls
  627. back to filament.name — matches the user-facing "clear preset" flow.
  628. """
  629. import json as _json
  630. mock_spoolman_client.ensure_extra_field = AsyncMock(return_value=True)
  631. response = await async_client.patch(
  632. "/api/v1/spoolman/inventory/spools/42",
  633. json={"slicer_filament": "", "slicer_filament_name": ""},
  634. )
  635. assert response.status_code == 200
  636. mock_spoolman_client.merge_spool_extra.assert_called_once_with(
  637. 42,
  638. {
  639. "bambu_slicer_filament": _json.dumps(""),
  640. "bambu_slicer_filament_name": _json.dumps(""),
  641. },
  642. )
  643. class TestSpoolmanInventoryCostPerKg:
  644. """Tests for the two-step cost_per_kg create path (PT-C2)."""
  645. @pytest.mark.asyncio
  646. @pytest.mark.integration
  647. async def test_create_spool_with_cost_per_kg_calls_price_update(
  648. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  649. ):
  650. """POST with cost_per_kg calls update_spool_full with price= after creation."""
  651. from unittest.mock import AsyncMock
  652. mock_spoolman_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  653. payload = {
  654. "material": "PLA",
  655. "brand": "Bambu Lab",
  656. "label_weight": 1000,
  657. "cost_per_kg": 24.99,
  658. }
  659. resp = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  660. assert resp.status_code == 200
  661. # update_spool_full must have been called with price=24.99
  662. calls = [
  663. c
  664. for c in mock_spoolman_client.update_spool_full.call_args_list
  665. if c.kwargs.get("price") == 24.99 or (c.args and 24.99 in c.args)
  666. ]
  667. assert len(calls) >= 1
  668. @pytest.mark.asyncio
  669. @pytest.mark.integration
  670. async def test_create_spool_without_cost_per_kg_skips_price_update(
  671. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  672. ):
  673. """POST without cost_per_kg does not call update_spool_full."""
  674. from unittest.mock import AsyncMock
  675. mock_spoolman_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  676. payload = {"material": "PLA", "brand": "Bambu Lab", "label_weight": 1000}
  677. resp = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  678. assert resp.status_code == 200
  679. mock_spoolman_client.update_spool_full.assert_not_called()
  680. class TestSpoolmanInventoryInputValidation:
  681. """Tests for input validation added as security hardening."""
  682. @pytest.mark.asyncio
  683. @pytest.mark.integration
  684. async def test_create_rejects_material_too_long(
  685. self,
  686. async_client: AsyncClient,
  687. spoolman_settings,
  688. mock_spoolman_client,
  689. ):
  690. """material longer than 64 chars is rejected with 422."""
  691. payload = {"material": "A" * 65, "label_weight": 1000, "weight_used": 0}
  692. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  693. assert response.status_code == 422
  694. @pytest.mark.asyncio
  695. @pytest.mark.integration
  696. async def test_create_rejects_note_too_long(
  697. self,
  698. async_client: AsyncClient,
  699. spoolman_settings,
  700. mock_spoolman_client,
  701. ):
  702. """note longer than 1000 chars is rejected with 422."""
  703. payload = {
  704. "material": "PLA",
  705. "label_weight": 1000,
  706. "weight_used": 0,
  707. "note": "x" * 1001,
  708. }
  709. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  710. assert response.status_code == 422
  711. @pytest.mark.asyncio
  712. @pytest.mark.integration
  713. async def test_create_rejects_negative_weight_used(
  714. self,
  715. async_client: AsyncClient,
  716. spoolman_settings,
  717. mock_spoolman_client,
  718. ):
  719. """Negative weight_used is rejected with 422."""
  720. payload = {"material": "PLA", "label_weight": 1000, "weight_used": -1.0}
  721. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  722. assert response.status_code == 422
  723. @pytest.mark.asyncio
  724. @pytest.mark.integration
  725. async def test_create_rejects_zero_label_weight(
  726. self,
  727. async_client: AsyncClient,
  728. spoolman_settings,
  729. mock_spoolman_client,
  730. ):
  731. """label_weight of 0 is rejected (minimum is 1)."""
  732. payload = {"material": "PLA", "label_weight": 0, "weight_used": 0}
  733. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  734. assert response.status_code == 422
  735. @pytest.mark.asyncio
  736. @pytest.mark.integration
  737. async def test_create_rejects_invalid_rgba(
  738. self,
  739. async_client: AsyncClient,
  740. spoolman_settings,
  741. mock_spoolman_client,
  742. ):
  743. """Non-hex rgba string is rejected with 422."""
  744. payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "GGGGGGFF"}
  745. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  746. assert response.status_code == 422
  747. @pytest.mark.asyncio
  748. @pytest.mark.integration
  749. async def test_create_accepts_valid_6char_rgba(
  750. self,
  751. async_client: AsyncClient,
  752. spoolman_settings,
  753. mock_spoolman_client,
  754. ):
  755. """A valid 6-char hex rgba is accepted."""
  756. payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "FF0000"}
  757. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  758. assert response.status_code == 200
  759. @pytest.mark.asyncio
  760. @pytest.mark.integration
  761. async def test_weight_update_rejects_negative_grams(
  762. self,
  763. async_client: AsyncClient,
  764. spoolman_settings,
  765. mock_spoolman_client,
  766. ):
  767. """Negative weight_grams on weight sync endpoint is rejected with 422."""
  768. response = await async_client.patch(
  769. "/api/v1/spoolman/inventory/spools/42/weight",
  770. json={"weight_grams": -50.0},
  771. )
  772. assert response.status_code == 422
  773. @pytest.mark.asyncio
  774. @pytest.mark.integration
  775. async def test_update_rejects_tag_uid_too_long(
  776. self,
  777. async_client: AsyncClient,
  778. spoolman_settings,
  779. mock_spoolman_client,
  780. ):
  781. """tag_uid longer than 30 chars is rejected with 422 (NFC UID max 10 bytes = 20 hex chars, capped at 30)."""
  782. payload = {"tag_uid": "A" * 65}
  783. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  784. assert response.status_code == 422
  785. @pytest.mark.asyncio
  786. @pytest.mark.integration
  787. async def test_update_rejects_tray_uuid_too_long(
  788. self,
  789. async_client: AsyncClient,
  790. spoolman_settings,
  791. mock_spoolman_client,
  792. ):
  793. """tray_uuid longer than 32 chars is rejected with 422."""
  794. payload = {"tray_uuid": "B" * 65}
  795. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  796. assert response.status_code == 422
  797. @pytest.mark.asyncio
  798. @pytest.mark.integration
  799. @pytest.mark.parametrize("uuid_len", [16, 31])
  800. async def test_update_rejects_tray_uuid_too_short(
  801. self,
  802. async_client: AsyncClient,
  803. spoolman_settings,
  804. mock_spoolman_client,
  805. uuid_len: int,
  806. ):
  807. """tray_uuid shorter than 32 chars is rejected (min_length=max_length=32)."""
  808. payload = {"tray_uuid": "A" * uuid_len}
  809. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  810. assert response.status_code == 422
  811. @pytest.mark.asyncio
  812. @pytest.mark.integration
  813. async def test_update_rejects_rgba_nine_chars(
  814. self,
  815. async_client: AsyncClient,
  816. spoolman_settings,
  817. mock_spoolman_client,
  818. ):
  819. """rgba must be max 8 hex chars; 9-char value is rejected with 422."""
  820. payload = {"rgba": "FF0000FFA"} # 9 chars
  821. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  822. assert response.status_code == 422
  823. @pytest.mark.asyncio
  824. @pytest.mark.integration
  825. async def test_tag_uid_below_min_length_rejected(
  826. self,
  827. async_client: AsyncClient,
  828. spoolman_settings,
  829. mock_spoolman_client,
  830. ):
  831. """tag_uid shorter than 8 hex chars is rejected with 422 (PT-I5)."""
  832. payload = {"tag_uid": "AABBCC"} # 6 chars, below min_length=8
  833. resp = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  834. assert resp.status_code == 422
  835. @pytest.mark.asyncio
  836. @pytest.mark.integration
  837. async def test_invalid_spoolman_url_scheme_returns_400(
  838. self,
  839. async_client: AsyncClient,
  840. db_session,
  841. mock_spoolman_client,
  842. ):
  843. """A spoolman_url with a non-http(s) scheme is rejected."""
  844. from backend.app.models.settings import Settings
  845. db_session.add(Settings(key="spoolman_enabled", value="true"))
  846. db_session.add(Settings(key="spoolman_url", value="ftp://evil.internal/"))
  847. await db_session.commit()
  848. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  849. assert response.status_code == 400
  850. assert "http" in response.json()["detail"].lower()
  851. @pytest.mark.asyncio
  852. @pytest.mark.integration
  853. @pytest.mark.parametrize(
  854. "evil_url",
  855. [
  856. "file:///etc/passwd",
  857. "gopher://127.0.0.1:70/",
  858. "dict://internal.corp/",
  859. "javascript:alert(1)",
  860. "http://169.254.169.254/latest/meta-data/", # AWS IMDS
  861. "http://100.100.100.200/", # Alibaba Cloud metadata
  862. "http://[fd00:ec2::254]/", # AWS IMDS IPv6
  863. "http://0.0.0.0/", # unspecified
  864. "http://224.0.0.1/", # IPv4 multicast
  865. "http://[ff02::1]/", # IPv6 multicast
  866. "http://[::ffff:169.254.169.254]/", # IPv4-mapped IPv6 IMDS bypass
  867. "http://2130706433/", # decimal-encoded 127.0.0.1
  868. "http://0x7f000001/", # hex-encoded 127.0.0.1
  869. ],
  870. )
  871. async def test_ssrf_blocked_schemes_and_addresses(
  872. self,
  873. async_client: AsyncClient,
  874. db_session,
  875. mock_spoolman_client,
  876. evil_url: str,
  877. ):
  878. """SSRF: dangerous schemes, cloud metadata IPs, multicast, unspecified,
  879. and numeric-encoded IPs must be rejected with 400. Loopback and
  880. RFC-1918 private ranges are allowed — they are legitimate Spoolman
  881. topologies for self-hosted Bambuddy deployments."""
  882. from backend.app.models.settings import Settings
  883. db_session.add(Settings(key="spoolman_enabled", value="true"))
  884. db_session.add(Settings(key="spoolman_url", value=evil_url))
  885. await db_session.commit()
  886. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  887. assert response.status_code == 400, (
  888. f"Expected 400 for SSRF URL {evil_url!r} but got {response.status_code}: {response.json()}"
  889. )
  890. @pytest.mark.asyncio
  891. @pytest.mark.integration
  892. @pytest.mark.parametrize(
  893. "lan_url",
  894. [
  895. "http://127.0.0.1:7912/", # loopback
  896. "http://[::1]:7912/", # IPv6 loopback
  897. "http://192.168.1.50:7912/", # RFC-1918 /16
  898. "http://10.0.0.5:7912/", # RFC-1918 /8
  899. "http://172.20.0.3:7912/", # RFC-1918 /12
  900. ],
  901. )
  902. async def test_ssrf_allows_lan_spoolman_topologies(
  903. self,
  904. async_client: AsyncClient,
  905. db_session,
  906. mock_spoolman_client,
  907. lan_url: str,
  908. ):
  909. """Regression: Bambuddy's normal deployment is LAN-local Spoolman.
  910. Loopback and RFC-1918 private addresses must NOT be rejected as SSRF."""
  911. from backend.app.models.settings import Settings
  912. db_session.add(Settings(key="spoolman_enabled", value="true"))
  913. db_session.add(Settings(key="spoolman_url", value=lan_url))
  914. await db_session.commit()
  915. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  916. assert response.status_code != 400, f"LAN URL {lan_url!r} was incorrectly blocked as SSRF: {response.json()}"
  917. @pytest.mark.asyncio
  918. @pytest.mark.integration
  919. async def test_create_rejects_storage_location_too_long(
  920. self,
  921. async_client: AsyncClient,
  922. spoolman_settings,
  923. mock_spoolman_client,
  924. ):
  925. """storage_location longer than 255 chars is rejected with 422."""
  926. payload = {
  927. "material": "PLA",
  928. "label_weight": 1000,
  929. "weight_used": 0,
  930. "storage_location": "x" * 256,
  931. }
  932. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  933. assert response.status_code == 422
  934. @pytest.mark.asyncio
  935. @pytest.mark.integration
  936. async def test_update_rejects_storage_location_too_long(
  937. self,
  938. async_client: AsyncClient,
  939. spoolman_settings,
  940. mock_spoolman_client,
  941. ):
  942. """storage_location longer than 255 chars on PATCH is rejected with 422."""
  943. payload = {"storage_location": "y" * 256}
  944. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  945. assert response.status_code == 422
  946. class TestStorageLocationPassthrough:
  947. """Tests that storage_location is correctly passed to and from Spoolman."""
  948. @pytest.mark.asyncio
  949. @pytest.mark.integration
  950. async def test_list_spools_maps_spoolman_location_to_storage_location(
  951. self,
  952. async_client: AsyncClient,
  953. spoolman_settings,
  954. mock_spoolman_client,
  955. ):
  956. """Spoolman's location field is exposed as storage_location in the response."""
  957. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  958. spool = response.json()[0]
  959. assert spool["storage_location"] == "Printer1 - AMS A1"
  960. @pytest.mark.asyncio
  961. @pytest.mark.integration
  962. async def test_list_spools_null_location_gives_null_storage_location(
  963. self,
  964. async_client: AsyncClient,
  965. spoolman_settings,
  966. mock_spoolman_client,
  967. ):
  968. """A Spoolman spool with no location gives null storage_location."""
  969. spool_no_loc = {**SAMPLE_SPOOLMAN_SPOOL, "location": None}
  970. mock_spoolman_client.get_all_spools.return_value = [spool_no_loc]
  971. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  972. spool = response.json()[0]
  973. assert spool["storage_location"] is None
  974. @pytest.mark.asyncio
  975. @pytest.mark.integration
  976. async def test_create_passes_storage_location_to_spoolman(
  977. self,
  978. async_client: AsyncClient,
  979. spoolman_settings,
  980. mock_spoolman_client,
  981. ):
  982. """storage_location is forwarded as location when creating a Spoolman spool."""
  983. payload = {
  984. "material": "PLA",
  985. "label_weight": 1000,
  986. "weight_used": 0,
  987. "storage_location": "Shelf B",
  988. }
  989. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  990. assert response.status_code == 200
  991. mock_spoolman_client.create_spool.assert_called_once()
  992. _, kwargs = mock_spoolman_client.create_spool.call_args
  993. assert kwargs.get("location") == "Shelf B"
  994. @pytest.mark.asyncio
  995. @pytest.mark.integration
  996. async def test_update_passes_storage_location_to_spoolman(
  997. self,
  998. async_client: AsyncClient,
  999. spoolman_settings,
  1000. mock_spoolman_client,
  1001. ):
  1002. """storage_location is forwarded as location when updating a Spoolman spool."""
  1003. payload = {"storage_location": "Drawer 3"}
  1004. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  1005. assert response.status_code == 200
  1006. mock_spoolman_client.update_spool_full.assert_called_once()
  1007. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  1008. assert kwargs.get("location") == "Drawer 3"
  1009. assert kwargs.get("clear_location") is False
  1010. @pytest.mark.asyncio
  1011. @pytest.mark.integration
  1012. async def test_update_clears_storage_location_when_null_sent(
  1013. self,
  1014. async_client: AsyncClient,
  1015. spoolman_settings,
  1016. mock_spoolman_client,
  1017. ):
  1018. """Explicitly sending null storage_location clears the Spoolman location."""
  1019. payload = {"storage_location": None}
  1020. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  1021. assert response.status_code == 200
  1022. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  1023. assert kwargs.get("clear_location") is True
  1024. @pytest.mark.asyncio
  1025. @pytest.mark.integration
  1026. async def test_update_clears_storage_location_when_empty_string_sent(
  1027. self,
  1028. async_client: AsyncClient,
  1029. spoolman_settings,
  1030. mock_spoolman_client,
  1031. ):
  1032. """Sending an empty string for storage_location also clears the Spoolman location."""
  1033. payload = {"storage_location": ""}
  1034. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  1035. assert response.status_code == 200
  1036. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  1037. assert kwargs.get("clear_location") is True
  1038. @pytest.mark.asyncio
  1039. @pytest.mark.integration
  1040. async def test_update_omitting_storage_location_does_not_write_location_to_spoolman(
  1041. self,
  1042. async_client: AsyncClient,
  1043. spoolman_settings,
  1044. mock_spoolman_client,
  1045. ):
  1046. """PATCH without storage_location in the payload must not touch Spoolman's location field.
  1047. Regression test for the round-trip bug: opening the edit modal and saving without
  1048. changing the location would previously echo the current Spoolman value back
  1049. (storage_location_changed=False branch used current.get("location") instead of None).
  1050. """
  1051. # Payload deliberately omits storage_location — simulates saving the modal
  1052. # without touching that field.
  1053. payload = {"note": "just updating the note"}
  1054. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  1055. assert response.status_code == 200
  1056. mock_spoolman_client.update_spool_full.assert_called_once()
  1057. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  1058. # location must be None so update_spool_full skips writing the field entirely
  1059. assert kwargs.get("location") is None
  1060. # clear_location must also be False — we are not explicitly clearing it either
  1061. assert kwargs.get("clear_location") is False
  1062. class TestColorNamePassthrough:
  1063. """color_name persistence via spool.extra.bambu_color_name (#1357).
  1064. Spoolman 0.23.1 has no `color_name` field on Filament, so Bambuddy owns
  1065. the round-trip via the spool's extra dict — same shape as the existing
  1066. bambu_slicer_filament storage. These tests pin that the create/update
  1067. routes register the extra field and write to merge_spool_extra, NOT to
  1068. find_or_create_filament's color_name parameter.
  1069. """
  1070. @pytest.mark.asyncio
  1071. @pytest.mark.integration
  1072. async def test_create_writes_color_name_to_spool_extra(
  1073. self,
  1074. async_client: AsyncClient,
  1075. spoolman_settings,
  1076. mock_spoolman_client,
  1077. ):
  1078. """color_name from create payload lands in spool.extra.bambu_color_name."""
  1079. import json as _json
  1080. payload = {
  1081. "material": "PLA",
  1082. "label_weight": 1000,
  1083. "weight_used": 0,
  1084. "color_name": "Bambu Green",
  1085. }
  1086. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  1087. assert response.status_code == 200
  1088. mock_spoolman_client.ensure_extra_field.assert_any_call("bambu_color_name")
  1089. mock_spoolman_client.merge_spool_extra.assert_called_once()
  1090. args = mock_spoolman_client.merge_spool_extra.call_args.args
  1091. extra_patch = args[1]
  1092. assert _json.loads(extra_patch["bambu_color_name"]) == "Bambu Green"
  1093. @pytest.mark.asyncio
  1094. @pytest.mark.integration
  1095. async def test_update_writes_color_name_to_spool_extra(
  1096. self,
  1097. async_client: AsyncClient,
  1098. spoolman_settings,
  1099. mock_spoolman_client,
  1100. ):
  1101. """color_name from update payload lands in spool.extra.bambu_color_name —
  1102. this is the #1357 reproduction: previously the value went to
  1103. filament.color_name which Spoolman silently dropped."""
  1104. import json as _json
  1105. payload = {"color_name": "Jade White"}
  1106. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  1107. assert response.status_code == 200
  1108. mock_spoolman_client.ensure_extra_field.assert_any_call("bambu_color_name")
  1109. mock_spoolman_client.merge_spool_extra.assert_called_once()
  1110. args = mock_spoolman_client.merge_spool_extra.call_args.args
  1111. extra_patch = args[1]
  1112. assert _json.loads(extra_patch["bambu_color_name"]) == "Jade White"
  1113. @pytest.mark.asyncio
  1114. @pytest.mark.integration
  1115. async def test_update_omits_color_name_skips_extra_write(
  1116. self,
  1117. async_client: AsyncClient,
  1118. spoolman_settings,
  1119. mock_spoolman_client,
  1120. ):
  1121. """When color_name is absent from the PATCH body, the route must not
  1122. write to spool.extra at all (preserves any existing value)."""
  1123. payload = {"note": "no color_name here"}
  1124. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  1125. assert response.status_code == 200
  1126. color_name_calls = [
  1127. c
  1128. for c in mock_spoolman_client.ensure_extra_field.call_args_list
  1129. if c.args and c.args[0] == "bambu_color_name"
  1130. ]
  1131. assert color_name_calls == []
  1132. class TestSpoolmanInventoryAuth:
  1133. """Write/delete endpoints require INVENTORY_UPDATE when auth is enabled."""
  1134. @pytest.fixture
  1135. async def auth_and_spoolman_settings(self, db_session):
  1136. """Enable both Spoolman and auth."""
  1137. from backend.app.models.settings import Settings
  1138. db_session.add(Settings(key="spoolman_enabled", value="true"))
  1139. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  1140. db_session.add(Settings(key="auth_enabled", value="true"))
  1141. await db_session.commit()
  1142. @pytest.mark.asyncio
  1143. @pytest.mark.integration
  1144. @pytest.mark.parametrize(
  1145. "method,path,payload",
  1146. [
  1147. ("POST", "/api/v1/spoolman/inventory/spools", {"material": "PLA", "label_weight": 1000, "weight_used": 0}),
  1148. (
  1149. "POST",
  1150. "/api/v1/spoolman/inventory/spools/bulk",
  1151. {"spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0}, "quantity": 1},
  1152. ),
  1153. ("PATCH", "/api/v1/spoolman/inventory/spools/42", {"note": "x"}),
  1154. ("DELETE", "/api/v1/spoolman/inventory/spools/42", None),
  1155. ("POST", "/api/v1/spoolman/inventory/spools/42/archive", None),
  1156. ("POST", "/api/v1/spoolman/inventory/spools/42/restore", None),
  1157. ("PATCH", "/api/v1/spoolman/inventory/spools/42/weight", {"weight_grams": 100.0}),
  1158. ],
  1159. )
  1160. async def test_write_endpoints_require_auth(
  1161. self,
  1162. async_client: AsyncClient,
  1163. auth_and_spoolman_settings,
  1164. method: str,
  1165. path: str,
  1166. payload: dict | None,
  1167. ):
  1168. """All write/delete endpoints return 401 when auth is enabled and no token is provided."""
  1169. response = await async_client.request(method, path, json=payload)
  1170. assert response.status_code == 401, (
  1171. f"{method} {path} should require auth but got {response.status_code}: {response.json()}"
  1172. )
  1173. @pytest.mark.asyncio
  1174. @pytest.mark.integration
  1175. @pytest.mark.parametrize(
  1176. "method,path",
  1177. [
  1178. ("GET", "/api/v1/spoolman/inventory/spools"),
  1179. ("GET", "/api/v1/spoolman/inventory/spools/42"),
  1180. ],
  1181. )
  1182. async def test_read_endpoints_require_auth(
  1183. self,
  1184. async_client: AsyncClient,
  1185. auth_and_spoolman_settings,
  1186. method: str,
  1187. path: str,
  1188. ):
  1189. """Read endpoints also require auth when auth is enabled."""
  1190. response = await async_client.request(method, path)
  1191. assert response.status_code == 401, (
  1192. f"{method} {path} should require auth but got {response.status_code}: {response.json()}"
  1193. )
  1194. @pytest.fixture
  1195. async def viewer_token(self, db_session):
  1196. """Create a Viewer-group user (INVENTORY_READ only, no INVENTORY_UPDATE)."""
  1197. from sqlalchemy import select
  1198. from backend.app.core.auth import create_access_token, get_password_hash
  1199. from backend.app.models.group import Group
  1200. from backend.app.models.settings import Settings
  1201. from backend.app.models.user import User
  1202. db_session.add(Settings(key="spoolman_enabled", value="true"))
  1203. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  1204. db_session.add(Settings(key="auth_enabled", value="true"))
  1205. await db_session.commit()
  1206. viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
  1207. viewer = User(
  1208. username="sm_inv_viewer",
  1209. password_hash=get_password_hash("pw"),
  1210. is_active=True,
  1211. )
  1212. viewer.groups.append(viewer_group)
  1213. db_session.add(viewer)
  1214. await db_session.commit()
  1215. return create_access_token(data={"sub": viewer.username})
  1216. @pytest.mark.asyncio
  1217. @pytest.mark.integration
  1218. @pytest.mark.parametrize(
  1219. "method,path,payload",
  1220. [
  1221. ("POST", "/api/v1/spoolman/inventory/spools", {"material": "PLA", "label_weight": 1000, "weight_used": 0}),
  1222. (
  1223. "POST",
  1224. "/api/v1/spoolman/inventory/spools/bulk",
  1225. {"spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0}, "quantity": 1},
  1226. ),
  1227. ("PATCH", "/api/v1/spoolman/inventory/spools/42", {"note": "x"}),
  1228. ("DELETE", "/api/v1/spoolman/inventory/spools/42", None),
  1229. ("POST", "/api/v1/spoolman/inventory/spools/42/archive", None),
  1230. ("POST", "/api/v1/spoolman/inventory/spools/42/restore", None),
  1231. ("PATCH", "/api/v1/spoolman/inventory/spools/42/weight", {"weight_grams": 100.0}),
  1232. ],
  1233. )
  1234. async def test_write_endpoints_return_403_for_viewer(
  1235. self,
  1236. async_client: AsyncClient,
  1237. viewer_token,
  1238. method: str,
  1239. path: str,
  1240. payload: dict | None,
  1241. ):
  1242. """Viewer-group users (INVENTORY_READ, no INVENTORY_UPDATE) get 403 on write endpoints."""
  1243. response = await async_client.request(
  1244. method,
  1245. path,
  1246. json=payload,
  1247. headers={"Authorization": f"Bearer {viewer_token}"},
  1248. )
  1249. assert response.status_code == 403, (
  1250. f"{method} {path} should return 403 for read-only user but got {response.status_code}: {response.json()}"
  1251. )
  1252. # Error body must mention the permission string so a "banned-user middleware"
  1253. # regression (generic 403 with no permission context) doesn't pass silently.
  1254. detail = response.json().get("detail", "")
  1255. assert "inventory:update" in detail, f"Expected 'inventory:update' in 403 detail but got: {detail!r}"
  1256. # ---------------------------------------------------------------------------
  1257. # Additional regression tests for second-round review items
  1258. # ---------------------------------------------------------------------------
  1259. class TestSpoolmanInventorySecurityExtras:
  1260. """Additional security/validation tests added in second review round."""
  1261. @pytest.mark.asyncio
  1262. @pytest.mark.integration
  1263. async def test_create_rejects_double_hash_rgba(
  1264. self,
  1265. async_client: AsyncClient,
  1266. spoolman_settings,
  1267. mock_spoolman_client,
  1268. ):
  1269. """SEC-3: rgba like '##FF0000' (double hash) must be rejected with 422."""
  1270. payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "##FF0000"}
  1271. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  1272. assert response.status_code == 422
  1273. @pytest.mark.asyncio
  1274. @pytest.mark.integration
  1275. @pytest.mark.parametrize("spool_id", [0, -1])
  1276. async def test_path_param_non_positive_spool_id_returns_422(
  1277. self,
  1278. async_client: AsyncClient,
  1279. spoolman_settings,
  1280. mock_spoolman_client,
  1281. spool_id: int,
  1282. ):
  1283. """SEC-5: /spools/0 and /spools/-1 must be rejected with 422 (Path gt=0)."""
  1284. response = await async_client.get(f"/api/v1/spoolman/inventory/spools/{spool_id}")
  1285. assert response.status_code == 422, f"Expected 422 for spool_id={spool_id} but got {response.status_code}"
  1286. @pytest.mark.asyncio
  1287. @pytest.mark.integration
  1288. @pytest.mark.parametrize(
  1289. "tag_uid,expected_status",
  1290. [
  1291. # After B1 fix: non-null tag_uid on PATCH /spools/{id} is rejected (use /tag endpoint)
  1292. ("A" * 30, 422), # non-null → 422 (use /tag endpoint instead)
  1293. ("DEADBEEF12345678", 422), # non-null → 422 regardless of length
  1294. ("A" * 31, 422), # exceeds max_length — also 422
  1295. ("A" * 32, 422), # tray_uuid-length value — also 422
  1296. ],
  1297. )
  1298. async def test_tag_uid_length_boundary(
  1299. self,
  1300. async_client: AsyncClient,
  1301. spoolman_settings,
  1302. mock_spoolman_client,
  1303. tag_uid: str,
  1304. expected_status: int,
  1305. ):
  1306. """tag_uid on PATCH /spools/{id} — all non-null values are rejected (B1 fix; use /tag endpoint)."""
  1307. payload = {"tag_uid": tag_uid}
  1308. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  1309. assert response.status_code == expected_status, (
  1310. f"tag_uid len={len(tag_uid)}: expected {expected_status} but got {response.status_code}"
  1311. )
  1312. @pytest.mark.asyncio
  1313. @pytest.mark.integration
  1314. async def test_bulk_create_partial_failure_returns_207(
  1315. self,
  1316. async_client: AsyncClient,
  1317. spoolman_settings,
  1318. mock_spoolman_client,
  1319. ):
  1320. """I9: bulk create with quantity=3 where middle call fails → 207 Multi-Status."""
  1321. from backend.app.services.spoolman import SpoolmanUnavailableError
  1322. results = [SAMPLE_SPOOLMAN_SPOOL, SpoolmanUnavailableError("Spoolman down"), SAMPLE_SPOOLMAN_SPOOL]
  1323. mock_spoolman_client.create_spool.side_effect = results
  1324. payload = {
  1325. "spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0},
  1326. "quantity": 3,
  1327. }
  1328. response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  1329. assert response.status_code == 207, (
  1330. f"Expected 207 Multi-Status for partial failure but got {response.status_code}"
  1331. )
  1332. body = response.json()
  1333. assert isinstance(body, dict)
  1334. assert body["requested_count"] == 3
  1335. assert body["failed_count"] == 1
  1336. assert len(body["created"]) == 2
  1337. class TestTagClearPreservesExtraKeys:
  1338. """Regression test: clearing tag_uid must not wipe unrelated Spoolman extra fields."""
  1339. @pytest.mark.asyncio
  1340. @pytest.mark.integration
  1341. async def test_tag_clear_preserves_custom_extra_key(
  1342. self,
  1343. async_client: AsyncClient,
  1344. spoolman_settings,
  1345. mock_spoolman_client,
  1346. ):
  1347. """PATCH tag_uid=None clears tag without dropping unrelated extra keys.
  1348. Spoolman PATCHes the extra dict by MERGING — popping a key from the
  1349. dict and sending the rest doesn't actually clear it. The endpoint
  1350. sets tag = json.dumps("") explicitly; read-side filters strip the
  1351. wrapping quotes and treat the empty string as "no tag" (#1114).
  1352. """
  1353. import json as _json
  1354. spool_with_extra = {
  1355. **SAMPLE_SPOOLMAN_SPOOL,
  1356. "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"', "custom_key": "keep_me"},
  1357. }
  1358. mock_spoolman_client.get_spool = AsyncMock(return_value=spool_with_extra)
  1359. mock_spoolman_client.update_spool_full = AsyncMock(return_value=spool_with_extra)
  1360. response = await async_client.patch(
  1361. "/api/v1/spoolman/inventory/spools/42",
  1362. json={"tag_uid": None},
  1363. )
  1364. assert response.status_code == 200
  1365. mock_spoolman_client.update_spool_full.assert_called_once()
  1366. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  1367. sent_extra = kwargs.get("extra")
  1368. assert sent_extra is not None, "extra must be sent when tag is cleared"
  1369. assert sent_extra.get("tag") == _json.dumps(""), (
  1370. "tag must be set to JSON empty-string sentinel (Spoolman PATCH merges; "
  1371. "popping the key would leave the previous value in place)"
  1372. )
  1373. assert sent_extra.get("custom_key") == "keep_me", "unrelated extra keys must survive"
  1374. @pytest.mark.asyncio
  1375. @pytest.mark.integration
  1376. async def test_tag_clear_refetches_spool_inside_lock(
  1377. self,
  1378. async_client: AsyncClient,
  1379. spoolman_settings,
  1380. mock_spoolman_client,
  1381. ):
  1382. """B7: tag-clear does a fresh get_spool() re-fetch inside the lock, not the stale one.
  1383. Simulates a write that changes extra between the initial get_spool (used for
  1384. other field resolution) and the lock acquisition. The extra sent to
  1385. update_spool_full must come from the second (in-lock) fetch, not the first.
  1386. """
  1387. stale_extra = {"tag": '"AABBCCDD"', "custom_key": "stale_value"}
  1388. fresh_extra = {"tag": '"AABBCCDD"', "custom_key": "fresh_value"}
  1389. stale_spool = {**SAMPLE_SPOOLMAN_SPOOL, "extra": stale_extra}
  1390. fresh_spool = {**SAMPLE_SPOOLMAN_SPOOL, "extra": fresh_extra}
  1391. # First call returns stale; second call (inside lock) returns fresh
  1392. mock_spoolman_client.get_spool = AsyncMock(side_effect=[stale_spool, fresh_spool])
  1393. mock_spoolman_client.update_spool_full = AsyncMock(return_value=fresh_spool)
  1394. response = await async_client.patch(
  1395. "/api/v1/spoolman/inventory/spools/42",
  1396. json={"tag_uid": None, "tray_uuid": None},
  1397. )
  1398. assert response.status_code == 200
  1399. # get_spool called twice: once for field resolution, once for fresh extra fetch
  1400. assert mock_spoolman_client.get_spool.call_count == 2
  1401. import json as _json
  1402. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  1403. sent_extra = kwargs.get("extra")
  1404. assert sent_extra is not None
  1405. # Tag is set to the JSON empty-string sentinel (not popped) — Spoolman
  1406. # PATCH merges, so popping the key would leave the previous value.
  1407. assert sent_extra.get("tag") == _json.dumps("")
  1408. # custom_key must come from the fresh re-fetch, not the stale first fetch
  1409. assert sent_extra.get("custom_key") == "fresh_value"
  1410. class TestSpoolmanInventorySSRFSpoolBuddyPath:
  1411. """SSRF tests for _get_spoolman_client_or_none (nfc/* and scale/ endpoints)."""
  1412. @pytest.mark.asyncio
  1413. @pytest.mark.integration
  1414. @pytest.mark.parametrize(
  1415. "evil_url",
  1416. [
  1417. "file:///etc/passwd",
  1418. "http://169.254.169.254/latest/meta-data/", # AWS IMDS
  1419. "http://0.0.0.0/", # unspecified
  1420. "http://[::ffff:169.254.169.254]/", # IPv4-mapped IMDS bypass
  1421. ],
  1422. )
  1423. async def test_nfc_tag_scanned_with_ssrf_url_ignores_spoolman(
  1424. self,
  1425. async_client: AsyncClient,
  1426. db_session,
  1427. evil_url: str,
  1428. ):
  1429. """SSRF: _get_spoolman_client_or_none silently disables Spoolman for unsafe URLs
  1430. on the SpoolBuddy NFC path (tag-scanned broadcasts unknown_tag, not 400)."""
  1431. from backend.app.models.settings import Settings
  1432. db_session.add(Settings(key="spoolman_enabled", value="true"))
  1433. db_session.add(Settings(key="spoolman_url", value=evil_url))
  1434. await db_session.commit()
  1435. from unittest.mock import AsyncMock, patch
  1436. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1437. mock_ws.broadcast = AsyncMock()
  1438. resp = await async_client.post(
  1439. "/api/v1/spoolbuddy/nfc/tag-scanned",
  1440. json={"device_id": "sb-ssrf", "tag_uid": "AABBCCDD"},
  1441. )
  1442. # Must not crash or proxy the SSRF URL — unknown_tag is the safe degraded response
  1443. assert resp.status_code == 200
  1444. if mock_ws.broadcast.called:
  1445. msg = mock_ws.broadcast.call_args[0][0]
  1446. assert msg["type"] == "spoolbuddy_unknown_tag"
  1447. @pytest.mark.asyncio
  1448. @pytest.mark.integration
  1449. @pytest.mark.parametrize(
  1450. "evil_url",
  1451. [
  1452. "http://169.254.169.254/latest/meta-data/", # AWS IMDS
  1453. "http://[::ffff:169.254.169.254]/", # IPv4-mapped IMDS bypass
  1454. ],
  1455. )
  1456. async def test_nfc_write_result_with_ssrf_url_degrades_gracefully(
  1457. self,
  1458. async_client: AsyncClient,
  1459. db_session,
  1460. evil_url: str,
  1461. ):
  1462. """SSRF: write-result with unsafe Spoolman URL must not proxy to the evil host.
  1463. write-result calls Spoolman to write-back the tag UID when data_origin='spoolman'.
  1464. With an SSRF URL, _get_spoolman_client_or_none returns None so the call is skipped
  1465. and the route returns 502 (tag written but link not persisted — not a server crash).
  1466. """
  1467. import json as _json
  1468. from backend.app.models.settings import Settings
  1469. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  1470. db_session.add(Settings(key="spoolman_enabled", value="true"))
  1471. db_session.add(Settings(key="spoolman_url", value=evil_url))
  1472. # Register the device so the route doesn't 404 before reaching the SSRF guard.
  1473. db_session.add(
  1474. SpoolBuddyDevice(
  1475. device_id="sb-ssrf-wr",
  1476. hostname="sb-ssrf-wr.local",
  1477. ip_address="127.0.0.1",
  1478. pending_command="write_tag",
  1479. pending_write_payload=_json.dumps({"spool_id": 99, "ndef_data_hex": "DEAD", "data_origin": "spoolman"}),
  1480. )
  1481. )
  1482. await db_session.commit()
  1483. from unittest.mock import AsyncMock, patch
  1484. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1485. mock_ws.broadcast = AsyncMock()
  1486. resp = await async_client.post(
  1487. "/api/v1/spoolbuddy/nfc/write-result",
  1488. json={
  1489. "device_id": "sb-ssrf-wr",
  1490. "spool_id": 99,
  1491. "tag_uid": "AABBCCDD",
  1492. "success": True,
  1493. },
  1494. )
  1495. # 502 = tag written to NFC but Spoolman link not persisted (SSRF guard blocked it).
  1496. # Must not be 500 (crash) and must not have proxied to the evil host.
  1497. assert resp.status_code == 502
  1498. @pytest.mark.asyncio
  1499. @pytest.mark.integration
  1500. @pytest.mark.parametrize(
  1501. "evil_url",
  1502. [
  1503. "http://169.254.169.254/latest/meta-data/", # AWS IMDS
  1504. ],
  1505. )
  1506. async def test_scale_update_weight_with_ssrf_url_degrades_gracefully(
  1507. self,
  1508. async_client: AsyncClient,
  1509. db_session,
  1510. evil_url: str,
  1511. ):
  1512. """SSRF: scale weight update with unsafe Spoolman URL must not proxy to the evil host."""
  1513. from backend.app.models.settings import Settings
  1514. db_session.add(Settings(key="spoolman_enabled", value="true"))
  1515. db_session.add(Settings(key="spoolman_url", value=evil_url))
  1516. await db_session.commit()
  1517. from unittest.mock import AsyncMock, patch
  1518. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1519. mock_ws.broadcast = AsyncMock()
  1520. resp = await async_client.post(
  1521. "/api/v1/spoolbuddy/scale/update-spool-weight",
  1522. json={"device_id": "sb-ssrf-scale", "spool_id": 1, "weight_grams": 500.0},
  1523. )
  1524. # Must not crash or proxy to an SSRF host
  1525. assert resp.status_code in (200, 404, 422)
  1526. class TestMergeSpoolExtraPreservesKeys:
  1527. """Unit-level test for merge_spool_extra key preservation (via mocked Spoolman)."""
  1528. @pytest.mark.asyncio
  1529. @pytest.mark.integration
  1530. async def test_merge_preserves_unrelated_extra_keys(
  1531. self,
  1532. async_client: AsyncClient,
  1533. spoolman_settings,
  1534. mock_spoolman_client,
  1535. ):
  1536. """merge_spool_extra must deep-merge rather than overwrite the extra dict.
  1537. Seed extra={"custom_key": "keep_me", "tag": "old"}.
  1538. After merging {"tag": "new"}, the PATCH payload must still contain custom_key.
  1539. """
  1540. from unittest.mock import AsyncMock, patch
  1541. existing_spool = {
  1542. **SAMPLE_SPOOLMAN_SPOOL,
  1543. "extra": {"custom_key": "keep_me", "tag": '"old"'},
  1544. }
  1545. updated_spool = {**existing_spool, "extra": {"custom_key": "keep_me", "tag": '"new"'}}
  1546. mock_client = mock_spoolman_client
  1547. mock_client.get_spool = AsyncMock(return_value=existing_spool)
  1548. mock_client.update_spool_full = AsyncMock(return_value=updated_spool)
  1549. # Call merge_spool_extra directly through the service
  1550. from backend.app.services.spoolman import SpoolmanClient
  1551. client = SpoolmanClient.__new__(SpoolmanClient)
  1552. client.base_url = "http://localhost:7912"
  1553. client.api_url = "http://localhost:7912/api/v1"
  1554. client._extra_locks = {}
  1555. async def _mock_get(spool_id):
  1556. return existing_spool
  1557. async def _mock_update(spool_id, **kwargs):
  1558. # Capture what was actually sent
  1559. _mock_update.captured_extra = kwargs.get("extra")
  1560. return updated_spool
  1561. _mock_update.captured_extra = None
  1562. client.get_spool = _mock_get
  1563. client.update_spool_full = _mock_update
  1564. result = await client.merge_spool_extra(42, {"tag": '"new"'})
  1565. # The merged extra must include the unrelated key
  1566. assert _mock_update.captured_extra is not None
  1567. assert _mock_update.captured_extra.get("custom_key") == "keep_me"
  1568. assert _mock_update.captured_extra.get("tag") == '"new"'
  1569. assert result is not None
  1570. class TestGetClientValueError:
  1571. """Test the ValueError branch in _get_client when init_spoolman_client fails (Gap 5)."""
  1572. @pytest.mark.asyncio
  1573. @pytest.mark.integration
  1574. async def test_returns_400_when_init_spoolman_client_raises_value_error(
  1575. self, async_client: AsyncClient, spoolman_settings
  1576. ):
  1577. """If init_spoolman_client raises ValueError after SSRF check passes, return HTTP 400."""
  1578. with (
  1579. patch(
  1580. "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
  1581. AsyncMock(return_value=None),
  1582. ),
  1583. patch(
  1584. "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
  1585. AsyncMock(side_effect=ValueError("unsupported scheme")),
  1586. ),
  1587. ):
  1588. resp = await async_client.get("/api/v1/spoolman/inventory/spools")
  1589. assert resp.status_code == 400
  1590. assert "unsupported scheme" in resp.json()["detail"]
  1591. class TestBulkCreateWithPriceFailure:
  1592. """Test that bulk create handles price-update failures per C1/C8 semantics."""
  1593. @pytest.mark.asyncio
  1594. @pytest.mark.integration
  1595. async def test_bulk_create_price_503_moves_spool_to_failures(
  1596. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1597. ):
  1598. """When price update fails (503), the spool goes to failures — overall returns 207 if at least one succeeds."""
  1599. from backend.app.services.spoolman import SpoolmanUnavailableError
  1600. # First price update fails (SpoolmanUnavailableError → 503), second succeeds
  1601. mock_spoolman_client.update_spool_full = AsyncMock(
  1602. side_effect=[SpoolmanUnavailableError("price server down"), SAMPLE_SPOOLMAN_SPOOL]
  1603. )
  1604. mock_spoolman_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  1605. payload = {
  1606. "spool": {
  1607. "material": "PLA",
  1608. "brand": "Bambu Lab",
  1609. "label_weight": 1000,
  1610. "cost_per_kg": 19.99,
  1611. },
  1612. "quantity": 2,
  1613. }
  1614. resp = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  1615. # One spool succeeded, one failed (price 503) → 207 Partial
  1616. assert resp.status_code == 207
  1617. data = resp.json()
  1618. assert len(data["created"]) == 1
  1619. assert data["failed_count"] == 1
  1620. # Both Spoolman creates were attempted
  1621. assert mock_spoolman_client.create_spool.call_count == 2
  1622. # Both price updates were attempted
  1623. assert mock_spoolman_client.update_spool_full.call_count == 2
  1624. class TestSpoolTagLinkValidation:
  1625. """NEW-B1: /spools/{id}/tag endpoint validates tag_uid length and content."""
  1626. @pytest.mark.asyncio
  1627. @pytest.mark.integration
  1628. async def test_tag_uid_6_chars_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  1629. """tag_uid with 6 hex chars is rejected — minimum is 8 chars (4-byte UID)."""
  1630. resp = await async_client.patch(
  1631. "/api/v1/spoolman/inventory/spools/42/tag",
  1632. json={"tag_uid": "AABBCC"}, # 6 chars — below new minimum
  1633. )
  1634. assert resp.status_code == 422
  1635. @pytest.mark.asyncio
  1636. @pytest.mark.integration
  1637. async def test_tag_uid_all_zeros_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  1638. """tag_uid that is all-zero bytes is rejected as an unwritten/blank tag."""
  1639. resp = await async_client.patch(
  1640. "/api/v1/spoolman/inventory/spools/42/tag",
  1641. json={"tag_uid": "00000000000000"}, # 14 zeros
  1642. )
  1643. assert resp.status_code == 422
  1644. @pytest.mark.asyncio
  1645. @pytest.mark.integration
  1646. async def test_tag_uid_valid_14_chars_accepted(
  1647. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1648. ):
  1649. """tag_uid with 14 valid hex chars (7-byte UID) is accepted."""
  1650. # This tag is not in SAMPLE_SPOOLMAN_SPOOL so no duplicate conflict.
  1651. resp = await async_client.patch(
  1652. "/api/v1/spoolman/inventory/spools/42/tag",
  1653. json={"tag_uid": "AABBCCDD112233"}, # 14 chars, valid, not all-zeros
  1654. )
  1655. assert resp.status_code == 200
  1656. @pytest.mark.asyncio
  1657. @pytest.mark.integration
  1658. async def test_tag_uid_8_chars_accepted(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  1659. """tag_uid with 8 hex chars (4-byte Bambu Lab NFC UID) is accepted after min_length fix."""
  1660. resp = await async_client.patch(
  1661. "/api/v1/spoolman/inventory/spools/42/tag",
  1662. json={"tag_uid": "2728C17B"}, # 8 chars — real Bambu Lab 4-byte hardware UID
  1663. )
  1664. assert resp.status_code == 200
  1665. @pytest.mark.asyncio
  1666. @pytest.mark.integration
  1667. async def test_tag_uid_8_zeros_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  1668. """tag_uid with 8 zero chars is rejected — all-zeros validator applies at the new minimum."""
  1669. resp = await async_client.patch(
  1670. "/api/v1/spoolman/inventory/spools/42/tag",
  1671. json={"tag_uid": "00000000"}, # 8 zeros — meets min_length but is a blank/unwritten tag
  1672. )
  1673. assert resp.status_code == 422
  1674. class TestLinkTagDuplicate:
  1675. """NEW-I1: /spools/{id}/tag returns 409 when another spool already has the same tag."""
  1676. @pytest.mark.asyncio
  1677. @pytest.mark.integration
  1678. async def test_link_tag_returns_200_when_tag_not_on_another_spool(
  1679. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1680. ):
  1681. """Linking a fresh tag to spool 42 returns 200 — no duplicate in Spoolman."""
  1682. resp = await async_client.patch(
  1683. "/api/v1/spoolman/inventory/spools/42/tag",
  1684. json={"tag_uid": "AABBCCDD112233"}, # not in SAMPLE_SPOOLMAN_SPOOL
  1685. )
  1686. assert resp.status_code == 200
  1687. mock_spoolman_client.update_spool_full.assert_called_once()
  1688. @pytest.mark.asyncio
  1689. @pytest.mark.integration
  1690. async def test_link_tag_returns_409_when_same_tag_on_different_spool(
  1691. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1692. ):
  1693. """Linking spool 99 to a tag that spool 42 already carries must return 409."""
  1694. # SAMPLE_SPOOLMAN_SPOOL (id=42) has extra.tag = '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'.
  1695. # Attempting to assign the same tag to spool 99 must be rejected.
  1696. resp = await async_client.patch(
  1697. "/api/v1/spoolman/inventory/spools/99/tag",
  1698. json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"}, # 32-char tray UUID
  1699. )
  1700. assert resp.status_code == 409
  1701. detail = resp.json()["detail"]
  1702. assert "42" in str(detail)
  1703. class TestSpoolmanInventoryUpdateCoreWeight:
  1704. """core_weight is accepted for schema parity but not persisted — any value should be accepted."""
  1705. @pytest.mark.asyncio
  1706. @pytest.mark.integration
  1707. async def test_patch_core_weight_other_than_250_accepted(
  1708. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1709. ):
  1710. """PATCH with core_weight != 250 is accepted (field is ignored server-side, not rejected)."""
  1711. resp = await async_client.patch(
  1712. "/api/v1/spoolman/inventory/spools/42",
  1713. json={"core_weight": 100},
  1714. )
  1715. assert resp.status_code == 200
  1716. @pytest.mark.asyncio
  1717. @pytest.mark.integration
  1718. async def test_patch_core_weight_250_explicitly_is_accepted(
  1719. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1720. ):
  1721. """PATCH with core_weight=250 (the default) is valid and returns 200."""
  1722. resp = await async_client.patch(
  1723. "/api/v1/spoolman/inventory/spools/42",
  1724. json={"core_weight": 250},
  1725. )
  1726. assert resp.status_code == 200
  1727. @pytest.mark.asyncio
  1728. @pytest.mark.integration
  1729. async def test_patch_without_core_weight_is_accepted(
  1730. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1731. ):
  1732. """PATCH without core_weight (omitted) must not trigger the validator — returns 200."""
  1733. resp = await async_client.patch(
  1734. "/api/v1/spoolman/inventory/spools/42",
  1735. json={"note": "no core_weight key"},
  1736. )
  1737. assert resp.status_code == 200
  1738. class TestUnlinkSpool:
  1739. """POST /spoolman/spools/{id}/unlink clears Spoolman tag without re-entrant lock deadlock.
  1740. Spoolman PATCHes the extra dict by MERGING — popping a key + sending the
  1741. rest doesn't clear the popped key. The endpoint sends the JSON empty-string
  1742. sentinel ('""') which the read-side filters strip. (#1114)
  1743. The endpoint uses merge_spool_extra (not update_spool_full directly)
  1744. because (a) merge_spool_extra owns the per-spool extra_lock for atomic
  1745. read-modify-write semantics, and (b) wrapping it in another extra_lock
  1746. would deadlock — asyncio.Lock is not re-entrant.
  1747. """
  1748. @pytest.fixture
  1749. def mock_unlink_client(self):
  1750. """Mock Spoolman client for the spoolman.py (non-inventory) route."""
  1751. spool_with_tag = {
  1752. **SAMPLE_SPOOLMAN_SPOOL,
  1753. "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"', "custom": "keep"},
  1754. }
  1755. mock_client = MagicMock()
  1756. mock_client.base_url = "http://localhost:7912"
  1757. mock_client.health_check = AsyncMock(return_value=True)
  1758. mock_client.get_spool = AsyncMock(return_value=spool_with_tag)
  1759. # merge_spool_extra returns the spool with the tag cleared (and custom
  1760. # preserved) — that's what the read-side will see after the fix.
  1761. mock_client.merge_spool_extra = AsyncMock(
  1762. return_value={**spool_with_tag, "extra": {"tag": '""', "custom": "keep"}}
  1763. )
  1764. with (
  1765. patch(
  1766. "backend.app.api.routes.spoolman.get_spoolman_client",
  1767. AsyncMock(return_value=mock_client),
  1768. ),
  1769. patch(
  1770. "backend.app.api.routes.spoolman.init_spoolman_client",
  1771. AsyncMock(return_value=mock_client),
  1772. ),
  1773. ):
  1774. yield mock_client
  1775. @pytest.mark.asyncio
  1776. @pytest.mark.integration
  1777. async def test_unlink_sets_tag_to_json_empty_string(
  1778. self,
  1779. async_client: AsyncClient,
  1780. spoolman_settings,
  1781. mock_unlink_client,
  1782. ):
  1783. """Unlink calls merge_spool_extra with the JSON-empty-string sentinel.
  1784. Pre-fix the endpoint did `cur_extra.pop("tag")` then PATCHed the rest.
  1785. Spoolman silently kept the previous tag because the key wasn't in the
  1786. payload (PATCH merges). Now the endpoint sends `{"tag": '""'}` and
  1787. the read-side .strip('"') resolves it to "" → spool drops out of
  1788. get_linked_spools.
  1789. """
  1790. import json as _json
  1791. resp = await async_client.post("/api/v1/spoolman/spools/42/unlink")
  1792. assert resp.status_code == 200
  1793. mock_unlink_client.merge_spool_extra.assert_called_once_with(42, {"tag": _json.dumps("")})
  1794. @pytest.mark.asyncio
  1795. @pytest.mark.integration
  1796. async def test_unlink_preserves_other_extra_keys(
  1797. self,
  1798. async_client: AsyncClient,
  1799. spoolman_settings,
  1800. mock_unlink_client,
  1801. ):
  1802. """Unrelated extra keys must survive unlink.
  1803. merge_spool_extra is responsible for the merge (read current → merge
  1804. new fields → PATCH). The unlink endpoint only sends `{"tag": ...}`,
  1805. so any other extra key on the spool is automatically preserved by
  1806. merge_spool_extra's read-merge-write semantics.
  1807. """
  1808. resp = await async_client.post("/api/v1/spoolman/spools/42/unlink")
  1809. assert resp.status_code == 200
  1810. # The endpoint passes only the tag key — merge_spool_extra does the
  1811. # rest. We don't assert anything about `custom` on the call args
  1812. # because the route doesn't see / pass it.
  1813. _, args, _ = mock_unlink_client.merge_spool_extra.mock_calls[0]
  1814. sent_fields = args[1] if len(args) >= 2 else {}
  1815. assert sent_fields == {"tag": '""'}, "unlink should only send the tag key — merge_spool_extra does the merge"
  1816. # ---------------------------------------------------------------------------
  1817. # B1: GET /spoolman/inventory/filaments
  1818. # B2: POST /spools with spoolman_filament_id bypasses find_or_create_filament
  1819. # ---------------------------------------------------------------------------
  1820. SAMPLE_FILAMENT_DICT = {
  1821. "id": 7,
  1822. "name": "PLA Basic",
  1823. "material": "PLA",
  1824. "color_hex": "FF0000",
  1825. "color_name": "Red",
  1826. "weight": 1000,
  1827. "spool_weight": 196,
  1828. "vendor": {"id": 3, "name": "Bambu Lab"},
  1829. }
  1830. class TestListSpoolmanFilaments:
  1831. """Tests for GET /api/v1/spoolman/inventory/filaments (B1)."""
  1832. @pytest.mark.asyncio
  1833. @pytest.mark.integration
  1834. async def test_list_filaments_disabled_returns_400(self, async_client: AsyncClient):
  1835. """Without Spoolman enabled the endpoint returns 400."""
  1836. resp = await async_client.get("/api/v1/spoolman/inventory/filaments")
  1837. assert resp.status_code == 400
  1838. @pytest.mark.asyncio
  1839. @pytest.mark.integration
  1840. async def test_list_filaments_unreachable_returns_503(self, async_client: AsyncClient, spoolman_settings):
  1841. """503 is returned when _get_client raises HTTPException(503)."""
  1842. with patch(
  1843. "backend.app.api.routes.spoolman_inventory._get_client",
  1844. AsyncMock(side_effect=HTTPException(status_code=503, detail="Spoolman server is not reachable")),
  1845. ):
  1846. resp = await async_client.get("/api/v1/spoolman/inventory/filaments")
  1847. assert resp.status_code == 503
  1848. @pytest.mark.asyncio
  1849. @pytest.mark.integration
  1850. async def test_list_filaments_success(self, async_client: AsyncClient, spoolman_settings):
  1851. """Success path returns normalised filament list including spool_weight."""
  1852. mock_client = MagicMock()
  1853. mock_client.get_filaments = AsyncMock(return_value=[SAMPLE_FILAMENT_DICT])
  1854. with patch(
  1855. "backend.app.api.routes.spoolman_inventory._get_client",
  1856. AsyncMock(return_value=mock_client),
  1857. ):
  1858. resp = await async_client.get("/api/v1/spoolman/inventory/filaments")
  1859. assert resp.status_code == 200
  1860. data = resp.json()
  1861. assert isinstance(data, list)
  1862. assert len(data) == 1
  1863. entry = data[0]
  1864. assert entry["id"] == 7
  1865. assert entry["material"] == "PLA"
  1866. assert entry["spool_weight"] == 196
  1867. assert entry["vendor"]["name"] == "Bambu Lab"
  1868. class TestCreateSpoolWithFilamentId:
  1869. """Tests for POST /api/v1/spoolman/inventory/spools with spoolman_filament_id (B2)."""
  1870. @pytest.mark.asyncio
  1871. @pytest.mark.integration
  1872. async def test_create_with_filament_id_skips_find_or_create(self, async_client: AsyncClient, spoolman_settings):
  1873. """When spoolman_filament_id is provided, find_or_create_filament must NOT be called."""
  1874. mock_client = MagicMock()
  1875. mock_client.find_or_create_filament = AsyncMock(return_value=7)
  1876. mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  1877. mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  1878. with patch(
  1879. "backend.app.api.routes.spoolman_inventory._get_client",
  1880. AsyncMock(return_value=mock_client),
  1881. ):
  1882. resp = await async_client.post(
  1883. "/api/v1/spoolman/inventory/spools",
  1884. json={"spoolman_filament_id": 7},
  1885. )
  1886. assert resp.status_code == 200
  1887. mock_client.find_or_create_filament.assert_not_called()
  1888. mock_client.create_spool.assert_called_once()
  1889. _, kwargs = mock_client.create_spool.call_args
  1890. assert kwargs.get("filament_id") == 7
  1891. @pytest.mark.asyncio
  1892. @pytest.mark.integration
  1893. async def test_create_with_invalid_filament_id_returns_404(self, async_client: AsyncClient, spoolman_settings):
  1894. """An invalid spoolman_filament_id (not in Spoolman) must return 404."""
  1895. from backend.app.services.spoolman import SpoolmanNotFoundError
  1896. mock_client = MagicMock()
  1897. mock_client.create_spool = AsyncMock(side_effect=SpoolmanNotFoundError("filament not found"))
  1898. with patch(
  1899. "backend.app.api.routes.spoolman_inventory._get_client",
  1900. AsyncMock(return_value=mock_client),
  1901. ):
  1902. resp = await async_client.post(
  1903. "/api/v1/spoolman/inventory/spools",
  1904. json={"spoolman_filament_id": 9999},
  1905. )
  1906. assert resp.status_code == 404
  1907. assert "9999" in resp.json()["detail"]
  1908. # ---------------------------------------------------------------------------
  1909. # WICHTIG-12: Additional edge-case tests
  1910. # ---------------------------------------------------------------------------
  1911. class TestBulkCreateWithFilamentId:
  1912. """Bulk create with spoolman_filament_id skips find_or_create_filament."""
  1913. @pytest.mark.asyncio
  1914. @pytest.mark.integration
  1915. async def test_bulk_create_with_filament_id_skips_find_or_create(
  1916. self, async_client: AsyncClient, spoolman_settings
  1917. ):
  1918. """Bulk POST with spoolman_filament_id must NOT call find_or_create_filament."""
  1919. mock_client = MagicMock()
  1920. mock_client.find_or_create_filament = AsyncMock(return_value=7)
  1921. mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  1922. mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  1923. with patch(
  1924. "backend.app.api.routes.spoolman_inventory._get_client",
  1925. AsyncMock(return_value=mock_client),
  1926. ):
  1927. resp = await async_client.post(
  1928. "/api/v1/spoolman/inventory/spools/bulk",
  1929. json={"spool": {"spoolman_filament_id": 7}, "quantity": 2},
  1930. )
  1931. assert resp.status_code == 200
  1932. mock_client.find_or_create_filament.assert_not_called()
  1933. assert mock_client.create_spool.call_count == 2
  1934. for call in mock_client.create_spool.call_args_list:
  1935. _, kwargs = call
  1936. assert kwargs.get("filament_id") == 7
  1937. class TestCreateSpoolValidation:
  1938. """Validation edge cases for SpoolmanInventoryCreate."""
  1939. @pytest.mark.asyncio
  1940. @pytest.mark.integration
  1941. async def test_create_spool_filament_id_zero_returns_422(self, async_client: AsyncClient, spoolman_settings):
  1942. """spoolman_filament_id=0 must fail Field(gt=0) validation → 422."""
  1943. resp = await async_client.post(
  1944. "/api/v1/spoolman/inventory/spools",
  1945. json={"spoolman_filament_id": 0},
  1946. )
  1947. assert resp.status_code == 422
  1948. @pytest.mark.asyncio
  1949. @pytest.mark.integration
  1950. async def test_create_spool_without_material_or_filament_id_returns_422(
  1951. self, async_client: AsyncClient, spoolman_settings
  1952. ):
  1953. """Neither material nor spoolman_filament_id → model_validator must reject → 422."""
  1954. resp = await async_client.post(
  1955. "/api/v1/spoolman/inventory/spools",
  1956. json={"label_weight": 1000},
  1957. )
  1958. assert resp.status_code == 422
  1959. class TestNormalizeFilament:
  1960. """Unit-style tests for _normalize_filament helper (imported directly)."""
  1961. def test_normalize_filament_null_vendor(self):
  1962. from backend.app.api.routes.spoolman_inventory import _normalize_filament
  1963. result = _normalize_filament({"id": 5, "name": "PLA", "vendor": None})
  1964. assert result is not None
  1965. assert result["vendor"] is None
  1966. def test_normalize_filament_null_id_returns_none(self):
  1967. from backend.app.api.routes.spoolman_inventory import _normalize_filament
  1968. result = _normalize_filament({"id": None, "name": "PLA"})
  1969. assert result is None
  1970. def test_normalize_filament_zero_id_returns_none(self):
  1971. from backend.app.api.routes.spoolman_inventory import _normalize_filament
  1972. result = _normalize_filament({"id": 0, "name": "PLA"})
  1973. assert result is None
  1974. # ---------------------------------------------------------------------------
  1975. # F1: TestTranslateSpoolmanErrors — 502/404/503 paths through _translate_spoolman_errors
  1976. # ---------------------------------------------------------------------------
  1977. class TestTranslateSpoolmanErrors:
  1978. """F1: _translate_spoolman_errors() maps Spoolman exceptions to HTTP codes."""
  1979. @pytest.mark.asyncio
  1980. @pytest.mark.integration
  1981. async def test_spoolman_not_found_returns_404(
  1982. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1983. ):
  1984. """SpoolmanNotFoundError from get_spool → 404."""
  1985. from backend.app.services.spoolman import SpoolmanNotFoundError
  1986. mock_spoolman_client.get_spool.side_effect = SpoolmanNotFoundError("spool 999 not found")
  1987. resp = await async_client.get("/api/v1/spoolman/inventory/spools/999")
  1988. assert resp.status_code == 404
  1989. @pytest.mark.asyncio
  1990. @pytest.mark.integration
  1991. async def test_spoolman_unavailable_returns_503(
  1992. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1993. ):
  1994. """SpoolmanUnavailableError from get_spool → 503."""
  1995. from backend.app.services.spoolman import SpoolmanUnavailableError
  1996. mock_spoolman_client.get_spool.side_effect = SpoolmanUnavailableError("network error")
  1997. resp = await async_client.get("/api/v1/spoolman/inventory/spools/42")
  1998. assert resp.status_code == 503
  1999. @pytest.mark.asyncio
  2000. @pytest.mark.integration
  2001. async def test_spoolman_client_error_returns_502_with_upstream_status(
  2002. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  2003. ):
  2004. """SpoolmanClientError from get_spool → 502 with upstream_status in body."""
  2005. from backend.app.services.spoolman import SpoolmanClientError
  2006. mock_spoolman_client.get_spool.side_effect = SpoolmanClientError("Spoolman rejected", 422, "filament not found")
  2007. resp = await async_client.get("/api/v1/spoolman/inventory/spools/42")
  2008. assert resp.status_code == 502
  2009. body = resp.json()
  2010. assert body["detail"]["upstream_status"] == 422
  2011. assert body["detail"]["upstream_body"] == "filament not found"
  2012. # ---------------------------------------------------------------------------
  2013. # F2: _get_client health_check returns False → 503
  2014. # ---------------------------------------------------------------------------
  2015. class TestGetClientHealthCheckFalse:
  2016. """F2: _get_client raises 503 when health_check() returns False."""
  2017. @pytest.mark.asyncio
  2018. @pytest.mark.integration
  2019. async def test_returns_503_when_health_check_returns_false(
  2020. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  2021. ):
  2022. """health_check() → False should produce 503 on any inventory call."""
  2023. import time
  2024. import backend.app.api.routes.spoolman_inventory as inv_module
  2025. mock_spoolman_client.health_check = AsyncMock(return_value=False)
  2026. # Clear the TTL cache so health_check is actually called
  2027. inv_module._health_check_cache.clear()
  2028. resp = await async_client.get("/api/v1/spoolman/inventory/spools")
  2029. assert resp.status_code == 503
  2030. # ---------------------------------------------------------------------------
  2031. # F3: SpoolTagLinkRequest both fields null → 422
  2032. # ---------------------------------------------------------------------------
  2033. class TestSpoolTagLinkBothNull:
  2034. """F3: /spools/{id}/tag with both tag_uid and tray_uuid null → 422."""
  2035. @pytest.mark.asyncio
  2036. @pytest.mark.integration
  2037. async def test_both_null_returns_422(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
  2038. """Sending {} (both fields absent) → at_least_one validator → 422."""
  2039. resp = await async_client.patch(
  2040. "/api/v1/spoolman/inventory/spools/42/tag",
  2041. json={},
  2042. )
  2043. assert resp.status_code == 422
  2044. @pytest.mark.asyncio
  2045. @pytest.mark.integration
  2046. async def test_both_explicitly_null_returns_422(
  2047. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  2048. ):
  2049. """Sending {tag_uid: null, tray_uuid: null} → at_least_one validator → 422."""
  2050. resp = await async_client.patch(
  2051. "/api/v1/spoolman/inventory/spools/42/tag",
  2052. json={"tag_uid": None, "tray_uuid": None},
  2053. )
  2054. assert resp.status_code == 422
  2055. # ---------------------------------------------------------------------------
  2056. # F5: RBAC lists — missing endpoints
  2057. # ---------------------------------------------------------------------------
  2058. class TestSpoolmanInventoryAuthExtended:
  2059. """F5: Additional endpoints in RBAC auth/403 parametrize lists."""
  2060. @pytest.fixture
  2061. async def auth_and_spoolman_settings(self, db_session):
  2062. from backend.app.models.settings import Settings
  2063. db_session.add(Settings(key="spoolman_enabled", value="true"))
  2064. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  2065. db_session.add(Settings(key="auth_enabled", value="true"))
  2066. await db_session.commit()
  2067. @pytest.mark.asyncio
  2068. @pytest.mark.integration
  2069. @pytest.mark.parametrize(
  2070. "method,path,payload",
  2071. [
  2072. ("PATCH", "/api/v1/spoolman/inventory/spools/42/tag", {"tag_uid": "AABBCCDDEE112233"}),
  2073. ("POST", "/api/v1/spoolman/inventory/sync-ams-weights", {"printer_id": 1, "ams_data": []}),
  2074. ("PATCH", "/api/v1/spoolman/inventory/filaments/7", {"spool_weight": 196.0}),
  2075. ],
  2076. )
  2077. async def test_extended_write_endpoints_require_auth(
  2078. self,
  2079. async_client: AsyncClient,
  2080. auth_and_spoolman_settings,
  2081. method: str,
  2082. path: str,
  2083. payload: dict | None,
  2084. ):
  2085. """Additional write endpoints return 401 when auth is enabled and no token is provided."""
  2086. resp = await async_client.request(method, path, json=payload)
  2087. assert resp.status_code == 401, f"{method} {path} should require auth but got {resp.status_code}: {resp.json()}"
  2088. @pytest.mark.asyncio
  2089. @pytest.mark.integration
  2090. @pytest.mark.parametrize(
  2091. "method,path",
  2092. [
  2093. ("GET", "/api/v1/spoolman/inventory/filaments"),
  2094. ],
  2095. )
  2096. async def test_extended_read_endpoints_require_auth(
  2097. self,
  2098. async_client: AsyncClient,
  2099. auth_and_spoolman_settings,
  2100. method: str,
  2101. path: str,
  2102. ):
  2103. """Additional read endpoints return 401 when auth is enabled and no token is provided."""
  2104. resp = await async_client.request(method, path)
  2105. assert resp.status_code == 401, f"{method} {path} should require auth but got {resp.status_code}: {resp.json()}"
  2106. # ---------------------------------------------------------------------------
  2107. # F8: _normalize_filament negative ID returns None
  2108. # ---------------------------------------------------------------------------
  2109. class TestNormalizeFilamentNegativeId:
  2110. """F8: _normalize_filament with negative id → None (was only checking == 0)."""
  2111. def test_normalize_filament_negative_id_returns_none(self):
  2112. from backend.app.api.routes.spoolman_inventory import _normalize_filament
  2113. result = _normalize_filament({"id": -1, "name": "PLA"})
  2114. assert result is None
  2115. def test_normalize_filament_large_negative_id_returns_none(self):
  2116. from backend.app.api.routes.spoolman_inventory import _normalize_filament
  2117. result = _normalize_filament({"id": -999, "name": "PLA"})
  2118. assert result is None
  2119. # ---------------------------------------------------------------------------
  2120. # F9: weight_used > label_weight cross-field validator integration test
  2121. # ---------------------------------------------------------------------------
  2122. class TestCreateSpoolWeightValidation:
  2123. """F9: SpoolmanInventoryCreate.validate_weight_consistency cross-field validator."""
  2124. @pytest.mark.asyncio
  2125. @pytest.mark.integration
  2126. async def test_weight_used_exceeds_label_weight_returns_422(self, async_client: AsyncClient, spoolman_settings):
  2127. """weight_used > label_weight → cross-field validator → 422."""
  2128. resp = await async_client.post(
  2129. "/api/v1/spoolman/inventory/spools",
  2130. json={"material": "PLA", "label_weight": 500, "weight_used": 600},
  2131. )
  2132. assert resp.status_code == 422
  2133. @pytest.mark.asyncio
  2134. @pytest.mark.integration
  2135. async def test_weight_used_equals_label_weight_accepted(
  2136. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  2137. ):
  2138. """weight_used == label_weight is exactly at the boundary → should pass (201)."""
  2139. resp = await async_client.post(
  2140. "/api/v1/spoolman/inventory/spools",
  2141. json={"material": "PLA", "label_weight": 1000, "weight_used": 1000},
  2142. )
  2143. # 201 or 200 (spool created)
  2144. assert resp.status_code in (200, 201)
  2145. @pytest.mark.asyncio
  2146. @pytest.mark.integration
  2147. async def test_create_spool_with_non_default_core_weight_accepted(
  2148. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  2149. ):
  2150. """A3: core_weight != 250 must no longer be rejected → 201."""
  2151. resp = await async_client.post(
  2152. "/api/v1/spoolman/inventory/spools",
  2153. json={"material": "PLA", "label_weight": 1000, "weight_used": 0, "core_weight": 196},
  2154. )
  2155. assert resp.status_code in (200, 201)
  2156. @pytest.mark.asyncio
  2157. @pytest.mark.integration
  2158. async def test_update_spool_with_non_default_core_weight_accepted(
  2159. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  2160. ):
  2161. """A3: PATCH with core_weight != 250 must no longer return 422."""
  2162. resp = await async_client.patch(
  2163. "/api/v1/spoolman/inventory/spools/42",
  2164. json={"core_weight": 300},
  2165. )
  2166. assert resp.status_code == 200
  2167. # ---------------------------------------------------------------------------
  2168. # P8-T1: /slot-assignments/all enriches with printer_name + ams_label
  2169. # ---------------------------------------------------------------------------
  2170. class TestGetAllSlotAssignmentsEnriched:
  2171. """P8-T1: /slot-assignments/all enriches with printer_name + ams_label.
  2172. Regression for InventoryPage LOCATION column showing '-' for Spoolman
  2173. spools because the endpoint only returned 4 raw fields without the
  2174. printer_name + ams_label needed by the UI.
  2175. """
  2176. @pytest.mark.asyncio
  2177. @pytest.mark.integration
  2178. async def test_returns_printer_name_for_existing_printer(
  2179. self, async_client: AsyncClient, db_session, spoolman_settings
  2180. ):
  2181. """printer_name is enriched from the joined Printer relationship."""
  2182. from backend.app.models.printer import Printer
  2183. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2184. db_session.add(
  2185. Printer(
  2186. id=1,
  2187. name="Sully",
  2188. model="X1C",
  2189. serial_number="SN1",
  2190. ip_address="1.2.3.4",
  2191. access_code="",
  2192. )
  2193. )
  2194. db_session.add(
  2195. SpoolmanSlotAssignment(
  2196. printer_id=1,
  2197. ams_id=0,
  2198. tray_id=2,
  2199. spoolman_spool_id=216,
  2200. )
  2201. )
  2202. await db_session.commit()
  2203. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as mock_pm:
  2204. mock_pm.get_all_statuses.return_value = {}
  2205. resp = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
  2206. assert resp.status_code == 200
  2207. data = resp.json()
  2208. assert len(data) == 1
  2209. assert data[0]["printer_name"] == "Sully"
  2210. assert data[0]["spoolman_spool_id"] == 216
  2211. assert data[0]["ams_id"] == 0
  2212. assert data[0]["tray_id"] == 2
  2213. assert data[0]["ams_label"] is None
  2214. @pytest.mark.asyncio
  2215. @pytest.mark.integration
  2216. async def test_returns_ams_label_when_label_configured(
  2217. self, async_client: AsyncClient, db_session, spoolman_settings
  2218. ):
  2219. """ams_label is enriched from AmsLabel via printer MQTT serial map."""
  2220. from backend.app.models.ams_label import AmsLabel
  2221. from backend.app.models.printer import Printer
  2222. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2223. db_session.add(
  2224. Printer(
  2225. id=1,
  2226. name="Sully",
  2227. model="X1C",
  2228. serial_number="SN1",
  2229. ip_address="1.2.3.4",
  2230. access_code="",
  2231. )
  2232. )
  2233. db_session.add(AmsLabel(ams_serial_number="ABC123", label="Top Shelf"))
  2234. db_session.add(
  2235. SpoolmanSlotAssignment(
  2236. printer_id=1,
  2237. ams_id=0,
  2238. tray_id=2,
  2239. spoolman_spool_id=216,
  2240. )
  2241. )
  2242. await db_session.commit()
  2243. mock_state = MagicMock(raw_data={"ams": [{"id": 0, "sn": "ABC123"}]})
  2244. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as mock_pm:
  2245. mock_pm.get_all_statuses.return_value = {1: mock_state}
  2246. resp = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
  2247. assert resp.status_code == 200
  2248. assert resp.json()[0]["ams_label"] == "Top Shelf"
  2249. @pytest.mark.asyncio
  2250. @pytest.mark.integration
  2251. async def test_synthetic_ams_label_fallback(self, async_client: AsyncClient, db_session, spoolman_settings):
  2252. """Falls back to synthetic 'p{pid}a{ams_id}' key when no MQTT serial available."""
  2253. from backend.app.models.ams_label import AmsLabel
  2254. from backend.app.models.printer import Printer
  2255. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2256. db_session.add(
  2257. Printer(
  2258. id=1,
  2259. name="Sully",
  2260. model="X1C",
  2261. serial_number="SN1",
  2262. ip_address="1.2.3.4",
  2263. access_code="",
  2264. )
  2265. )
  2266. db_session.add(AmsLabel(ams_serial_number="p1a0", label="Synthetic Label"))
  2267. db_session.add(
  2268. SpoolmanSlotAssignment(
  2269. printer_id=1,
  2270. ams_id=0,
  2271. tray_id=2,
  2272. spoolman_spool_id=216,
  2273. )
  2274. )
  2275. await db_session.commit()
  2276. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as mock_pm:
  2277. mock_pm.get_all_statuses.return_value = {} # No live state -> synthetic key
  2278. resp = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
  2279. assert resp.json()[0]["ams_label"] == "Synthetic Label"
  2280. @pytest.mark.asyncio
  2281. @pytest.mark.integration
  2282. async def test_filter_by_printer_id_still_works(self, async_client: AsyncClient, db_session, spoolman_settings):
  2283. """Regression: ?printer_id=N still filters and enriches."""
  2284. from backend.app.models.printer import Printer
  2285. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2286. for pid in (1, 2):
  2287. db_session.add(
  2288. Printer(
  2289. id=pid,
  2290. name=f"P{pid}",
  2291. model="X1C",
  2292. serial_number=f"SN{pid}",
  2293. ip_address=f"1.2.3.{pid}",
  2294. access_code="",
  2295. )
  2296. )
  2297. db_session.add(
  2298. SpoolmanSlotAssignment(
  2299. printer_id=pid,
  2300. ams_id=0,
  2301. tray_id=0,
  2302. spoolman_spool_id=200 + pid,
  2303. )
  2304. )
  2305. await db_session.commit()
  2306. with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as mock_pm:
  2307. mock_pm.get_all_statuses.return_value = {}
  2308. resp = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all?printer_id=1")
  2309. data = resp.json()
  2310. assert len(data) == 1
  2311. assert data[0]["printer_id"] == 1
  2312. assert data[0]["printer_name"] == "P1"
  2313. assert data[0]["spoolman_spool_id"] == 201