test_spoolman_inventory_api.py 127 KB

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