test_printers_api.py 117 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899
  1. """Integration tests for Printers API endpoints.
  2. Tests the full request/response cycle for /api/v1/printers/ endpoints.
  3. """
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. from urllib.parse import unquote
  6. import pytest
  7. from httpx import AsyncClient
  8. from sqlalchemy import select
  9. class TestPrintersAPI:
  10. """Integration tests for /api/v1/printers/ endpoints."""
  11. # ========================================================================
  12. # List endpoints
  13. # ========================================================================
  14. @pytest.mark.asyncio
  15. @pytest.mark.integration
  16. async def test_list_printers_empty(self, async_client: AsyncClient):
  17. """Verify empty list is returned when no printers exist."""
  18. response = await async_client.get("/api/v1/printers/")
  19. assert response.status_code == 200
  20. assert response.json() == []
  21. @pytest.mark.asyncio
  22. @pytest.mark.integration
  23. async def test_list_printers_with_data(self, async_client: AsyncClient, printer_factory, db_session):
  24. """Verify list returns existing printers."""
  25. await printer_factory(name="Test Printer")
  26. response = await async_client.get("/api/v1/printers/")
  27. assert response.status_code == 200
  28. data = response.json()
  29. assert len(data) >= 1
  30. assert any(p["name"] == "Test Printer" for p in data)
  31. # ========================================================================
  32. # Create endpoints
  33. # ========================================================================
  34. @pytest.mark.asyncio
  35. @pytest.mark.integration
  36. async def test_create_printer(self, async_client: AsyncClient):
  37. """Verify printer can be created."""
  38. data = {
  39. "name": "New Printer",
  40. "serial_number": "00M09A111111111",
  41. "ip_address": "192.168.1.100",
  42. "access_code": "12345678",
  43. "is_active": True,
  44. "model": "X1C",
  45. }
  46. response = await async_client.post("/api/v1/printers/", json=data)
  47. assert response.status_code == 200
  48. result = response.json()
  49. assert result["name"] == "New Printer"
  50. assert result["serial_number"] == "00M09A111111111"
  51. assert result["model"] == "X1C"
  52. @pytest.mark.asyncio
  53. @pytest.mark.integration
  54. async def test_create_printer_with_hostname(self, async_client: AsyncClient):
  55. """Verify printer can be created with a hostname instead of IP address."""
  56. data = {
  57. "name": "DNS Printer",
  58. "serial_number": "00M09A555555555",
  59. "ip_address": "printer.local",
  60. "access_code": "12345678",
  61. "model": "P1S",
  62. }
  63. response = await async_client.post("/api/v1/printers/", json=data)
  64. assert response.status_code == 200
  65. result = response.json()
  66. assert result["name"] == "DNS Printer"
  67. assert result["ip_address"] == "printer.local"
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_create_printer_with_fqdn(self, async_client: AsyncClient):
  71. """Verify printer can be created with a fully qualified domain name."""
  72. data = {
  73. "name": "FQDN Printer",
  74. "serial_number": "00M09A666666666",
  75. "ip_address": "my-printer.home.lan",
  76. "access_code": "12345678",
  77. "model": "X1C",
  78. }
  79. response = await async_client.post("/api/v1/printers/", json=data)
  80. assert response.status_code == 200
  81. result = response.json()
  82. assert result["ip_address"] == "my-printer.home.lan"
  83. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_create_printer_invalid_hostname(self, async_client: AsyncClient):
  86. """Verify invalid hostnames are rejected."""
  87. data = {
  88. "name": "Bad Printer",
  89. "serial_number": "00M09A777777777",
  90. "ip_address": "-invalid",
  91. "access_code": "12345678",
  92. }
  93. response = await async_client.post("/api/v1/printers/", json=data)
  94. assert response.status_code == 422
  95. @pytest.mark.asyncio
  96. @pytest.mark.integration
  97. async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):
  98. """Verify duplicate serial number is rejected."""
  99. await printer_factory(serial_number="00M09A222222222")
  100. data = {
  101. "name": "Duplicate Printer",
  102. "serial_number": "00M09A222222222",
  103. "ip_address": "192.168.1.101",
  104. "access_code": "12345678",
  105. }
  106. response = await async_client.post("/api/v1/printers/", json=data)
  107. # Should fail due to duplicate serial
  108. assert response.status_code in [400, 409, 422, 500]
  109. # ========================================================================
  110. # Get single endpoint
  111. # ========================================================================
  112. @pytest.mark.asyncio
  113. @pytest.mark.integration
  114. async def test_get_printer(self, async_client: AsyncClient, printer_factory, db_session):
  115. """Verify single printer can be retrieved."""
  116. printer = await printer_factory(name="Get Test Printer")
  117. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  118. assert response.status_code == 200
  119. result = response.json()
  120. assert result["id"] == printer.id
  121. assert result["name"] == "Get Test Printer"
  122. @pytest.mark.asyncio
  123. @pytest.mark.integration
  124. async def test_get_printer_not_found(self, async_client: AsyncClient):
  125. """Verify 404 for non-existent printer."""
  126. response = await async_client.get("/api/v1/printers/9999")
  127. assert response.status_code == 404
  128. # ========================================================================
  129. # Update endpoints
  130. # ========================================================================
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_update_printer_name(self, async_client: AsyncClient, printer_factory, db_session):
  134. """Verify printer name can be updated."""
  135. printer = await printer_factory(name="Original Name")
  136. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated Name"})
  137. assert response.status_code == 200
  138. assert response.json()["name"] == "Updated Name"
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_update_printer_active_status(self, async_client: AsyncClient, printer_factory, db_session):
  142. """Verify printer active status can be updated."""
  143. printer = await printer_factory(is_active=True)
  144. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"is_active": False})
  145. assert response.status_code == 200
  146. assert response.json()["is_active"] is False
  147. @pytest.mark.asyncio
  148. @pytest.mark.integration
  149. async def test_update_printer_auto_archive(self, async_client: AsyncClient, printer_factory, db_session):
  150. """Verify auto_archive setting can be updated."""
  151. printer = await printer_factory(auto_archive=True)
  152. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"auto_archive": False})
  153. assert response.status_code == 200
  154. assert response.json()["auto_archive"] is False
  155. @pytest.mark.asyncio
  156. @pytest.mark.integration
  157. async def test_update_nonexistent_printer(self, async_client: AsyncClient):
  158. """Verify updating non-existent printer returns 404."""
  159. response = await async_client.patch("/api/v1/printers/9999", json={"name": "New Name"})
  160. assert response.status_code == 404
  161. # ========================================================================
  162. # Delete endpoints
  163. # ========================================================================
  164. @pytest.mark.asyncio
  165. @pytest.mark.integration
  166. async def test_delete_printer(self, async_client: AsyncClient, printer_factory, db_session):
  167. """Verify printer can be deleted."""
  168. printer = await printer_factory()
  169. printer_id = printer.id
  170. response = await async_client.delete(f"/api/v1/printers/{printer_id}")
  171. assert response.status_code == 200
  172. # Verify deleted
  173. response = await async_client.get(f"/api/v1/printers/{printer_id}")
  174. assert response.status_code == 404
  175. @pytest.mark.asyncio
  176. @pytest.mark.integration
  177. async def test_delete_nonexistent_printer(self, async_client: AsyncClient):
  178. """Verify deleting non-existent printer returns 404."""
  179. response = await async_client.delete("/api/v1/printers/9999")
  180. assert response.status_code == 404
  181. # ========================================================================
  182. # File download endpoint — non-ASCII filename regression (#1245)
  183. # ========================================================================
  184. @pytest.mark.asyncio
  185. @pytest.mark.integration
  186. @pytest.mark.parametrize(
  187. ("filename", "ascii_fallback"),
  188. [
  189. ("龙泡泡石墩子_p2s_ok.gcode.3mf", "p2s_ok.gcode.3mf"),
  190. ("こんにちは.gcode.3mf", "gcode.3mf"),
  191. ("résumé.gcode.3mf", "rsum.gcode.3mf"),
  192. ("مرحبا.gcode.3mf", "gcode.3mf"),
  193. ("文件.3mf", "3mf"),
  194. ("hello.3mf", "hello.3mf"),
  195. ],
  196. )
  197. async def test_download_printer_file_non_ascii_filename(
  198. self,
  199. async_client: AsyncClient,
  200. printer_factory,
  201. filename: str,
  202. ascii_fallback: str,
  203. db_session,
  204. ):
  205. """Non-ASCII filenames must not crash header encoding (issue #1245)."""
  206. printer = await printer_factory()
  207. file_bytes = b"fake 3mf content"
  208. with patch(
  209. "backend.app.api.routes.printers.download_file_bytes_async",
  210. new=AsyncMock(return_value=file_bytes),
  211. ):
  212. response = await async_client.get(
  213. f"/api/v1/printers/{printer.id}/files/download",
  214. params={"path": f"/cache/{filename}"},
  215. )
  216. assert response.status_code == 200
  217. assert response.content == file_bytes
  218. content_disposition = response.headers["content-disposition"]
  219. assert f'filename="{ascii_fallback}"' in content_disposition
  220. assert "filename*=UTF-8''" in content_disposition
  221. encoded_name = content_disposition.split("filename*=UTF-8''", 1)[1]
  222. assert unquote(encoded_name) == filename
  223. # ========================================================================
  224. # Status endpoint
  225. # ========================================================================
  226. @pytest.mark.asyncio
  227. @pytest.mark.integration
  228. async def test_get_printer_status(
  229. self, async_client: AsyncClient, printer_factory, mock_printer_manager, db_session
  230. ):
  231. """Verify printer status can be retrieved."""
  232. printer = await printer_factory()
  233. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  234. assert response.status_code == 200
  235. result = response.json()
  236. assert "connected" in result
  237. assert "state" in result
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_get_printer_status_not_found(self, async_client: AsyncClient):
  241. """Verify 404 for status of non-existent printer."""
  242. response = await async_client.get("/api/v1/printers/9999/status")
  243. assert response.status_code == 404
  244. @pytest.mark.asyncio
  245. @pytest.mark.integration
  246. async def test_get_printer_status_includes_fila_switch_when_installed(
  247. self, async_client: AsyncClient, printer_factory, db_session
  248. ):
  249. """When the FTS accessory is installed, the status response must include
  250. the fila_switch object with the routing arrays. See #1162.
  251. The accessory is detected from print.device.fila_switch in MQTT;
  252. we feed a PrinterState with FilaSwitchState(installed=True, ...) and
  253. confirm it survives the schema serialization round-trip.
  254. """
  255. from unittest.mock import MagicMock, patch
  256. from backend.app.services.bambu_mqtt import FilaSwitchState, PrinterState
  257. printer = await printer_factory()
  258. state = PrinterState()
  259. state.connected = True
  260. state.state = "IDLE"
  261. state.fila_switch = FilaSwitchState(
  262. installed=True,
  263. in_slots=[-1, 2],
  264. out_extruders=[0, 1],
  265. stat=0,
  266. info=2,
  267. )
  268. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  269. mock_pm.get_status = MagicMock(return_value=state)
  270. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  271. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  272. assert response.status_code == 200
  273. result = response.json()
  274. assert result["fila_switch"] is not None
  275. assert result["fila_switch"]["installed"] is True
  276. assert result["fila_switch"]["in_slots"] == [-1, 2]
  277. assert result["fila_switch"]["out_extruders"] == [0, 1]
  278. assert result["fila_switch"]["stat"] == 0
  279. assert result["fila_switch"]["info"] == 2
  280. @pytest.mark.asyncio
  281. @pytest.mark.integration
  282. async def test_cover_uses_dispatched_plate_when_gcode_file_lacks_path(
  283. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  284. ):
  285. """When firmware drops the plate path from gcode_file (e.g. P1S
  286. 01.10.00.00, #1166), the dispatched-plate record must take precedence
  287. and serve plate 4's thumbnail instead of falling back to plate_1.png."""
  288. import io
  289. import zipfile
  290. from unittest.mock import MagicMock, patch
  291. from backend.app.services.bambu_ftp import cache_3mf_download
  292. from backend.app.services.bambu_mqtt import PrinterState
  293. printer = await printer_factory()
  294. # Build a 3MF that mimics a "true" multi-plate archive: thumbnails
  295. # for plates 1..4 are all present, gcode files for plates 1..4 are
  296. # all present. Without the dispatch record we'd default to plate_1.png.
  297. threemf_path = tmp_path / "MyModel.3mf"
  298. with zipfile.ZipFile(threemf_path, "w") as zf:
  299. for plate in range(1, 5):
  300. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  301. zf.writestr(f"Metadata/plate_{plate}.gcode", f"; plate {plate} gcode\n")
  302. cache_3mf_download(printer.id, "MyModel.3mf", threemf_path)
  303. state = PrinterState()
  304. state.connected = True
  305. state.state = "RUNNING"
  306. state.subtask_name = "MyModel"
  307. state.gcode_file = "MyModel.3mf" # firmware drops plate path
  308. state.dispatched_plate_id = 4
  309. state.dispatched_subtask = "MyModel"
  310. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  311. mock_pm.get_status = MagicMock(return_value=state)
  312. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  313. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  314. assert response.status_code == 200
  315. assert response.content == b"PLATE_4_PNG"
  316. @pytest.mark.asyncio
  317. @pytest.mark.integration
  318. async def test_cover_3mf_scan_fallback_for_per_plate_archive(
  319. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  320. ):
  321. """Per-plate archives sliced separately in Bambu Studio contain a
  322. single Metadata/plate_N.gcode (the active plate) but bundle thumbnails
  323. for every plate. With no dispatch record (e.g. dispatched via Studio
  324. directly) and no plate path in gcode_file, the route must scan the
  325. 3MF and pick plate N's thumbnail. See #1166 option 4."""
  326. import zipfile
  327. from unittest.mock import MagicMock, patch
  328. from backend.app.services.bambu_ftp import cache_3mf_download
  329. from backend.app.services.bambu_mqtt import PrinterState
  330. printer = await printer_factory()
  331. # Per-plate archive: thumbnails for all plates, gcode for plate 3 only.
  332. threemf_path = tmp_path / "PerPlate.3mf"
  333. with zipfile.ZipFile(threemf_path, "w") as zf:
  334. for plate in range(1, 5):
  335. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  336. zf.writestr("Metadata/plate_3.gcode", "; only plate 3 has gcode\n")
  337. cache_3mf_download(printer.id, "PerPlate.3mf", threemf_path)
  338. state = PrinterState()
  339. state.connected = True
  340. state.state = "RUNNING"
  341. state.subtask_name = "PerPlate"
  342. state.gcode_file = "PerPlate.3mf"
  343. # No dispatch record (Studio-direct dispatch).
  344. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  345. mock_pm.get_status = MagicMock(return_value=state)
  346. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  347. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  348. assert response.status_code == 200
  349. assert response.content == b"PLATE_3_PNG"
  350. @pytest.mark.asyncio
  351. @pytest.mark.integration
  352. async def test_get_printer_status_omits_fila_switch_when_not_installed(
  353. self, async_client: AsyncClient, printer_factory, db_session
  354. ):
  355. """Without the FTS accessory, fila_switch must be null so the frontend
  356. keeps applying the per-extruder filter on regular dual-nozzle printers."""
  357. from unittest.mock import MagicMock, patch
  358. from backend.app.services.bambu_mqtt import PrinterState
  359. printer = await printer_factory()
  360. state = PrinterState()
  361. state.connected = True
  362. state.state = "IDLE"
  363. # default fila_switch — installed = False
  364. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  365. mock_pm.get_status = MagicMock(return_value=state)
  366. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  367. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  368. assert response.status_code == 200
  369. result = response.json()
  370. assert result["fila_switch"] is None
  371. # ========================================================================
  372. # Test connection endpoint
  373. # ========================================================================
  374. class TestPrinterDataIntegrity:
  375. """Tests for printer data integrity."""
  376. @pytest.mark.asyncio
  377. @pytest.mark.integration
  378. async def test_printer_stores_all_fields(self, async_client: AsyncClient, printer_factory, db_session):
  379. """Verify printer stores all fields correctly."""
  380. printer = await printer_factory(
  381. name="Full Test Printer",
  382. serial_number="00M09A444444444",
  383. ip_address="192.168.1.150",
  384. model="P1S",
  385. is_active=True,
  386. auto_archive=False,
  387. )
  388. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  389. assert response.status_code == 200
  390. result = response.json()
  391. assert result["name"] == "Full Test Printer"
  392. assert result["serial_number"] == "00M09A444444444"
  393. assert result["ip_address"] == "192.168.1.150"
  394. assert result["model"] == "P1S"
  395. assert result["is_active"] is True
  396. assert result["auto_archive"] is False
  397. @pytest.mark.asyncio
  398. @pytest.mark.integration
  399. async def test_printer_update_persists(self, async_client: AsyncClient, printer_factory, db_session):
  400. """CRITICAL: Verify printer updates persist."""
  401. printer = await printer_factory(name="Original", is_active=True)
  402. # Update
  403. await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated", "is_active": False})
  404. # Verify persistence
  405. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  406. result = response.json()
  407. assert result["name"] == "Updated"
  408. assert result["is_active"] is False
  409. # ========================================================================
  410. # Refresh status endpoint
  411. # ========================================================================
  412. @pytest.mark.asyncio
  413. @pytest.mark.integration
  414. async def test_refresh_status_not_found(self, async_client: AsyncClient):
  415. """Verify 404 for non-existent printer."""
  416. response = await async_client.post("/api/v1/printers/99999/refresh-status")
  417. assert response.status_code == 404
  418. @pytest.mark.asyncio
  419. @pytest.mark.integration
  420. async def test_refresh_status_not_connected(self, async_client: AsyncClient, printer_factory):
  421. """Verify 400 when printer is not connected."""
  422. printer = await printer_factory(name="Disconnected Printer")
  423. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  424. mock_pm.request_status_update.return_value = False
  425. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  426. assert response.status_code == 400
  427. assert "not connected" in response.json()["detail"].lower()
  428. @pytest.mark.asyncio
  429. @pytest.mark.integration
  430. async def test_refresh_status_success(self, async_client: AsyncClient, printer_factory):
  431. """Verify successful refresh request."""
  432. printer = await printer_factory(name="Connected Printer")
  433. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  434. mock_pm.request_status_update.return_value = True
  435. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  436. assert response.status_code == 200
  437. assert response.json()["status"] == "refresh_requested"
  438. mock_pm.request_status_update.assert_called_once_with(printer.id)
  439. # ========================================================================
  440. # Current print user endpoint (Issue #206)
  441. # ========================================================================
  442. @pytest.mark.asyncio
  443. @pytest.mark.integration
  444. async def test_get_current_print_user_not_found(self, async_client: AsyncClient):
  445. """Verify 404 for non-existent printer."""
  446. response = await async_client.get("/api/v1/printers/99999/current-print-user")
  447. assert response.status_code == 404
  448. @pytest.mark.asyncio
  449. @pytest.mark.integration
  450. async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):
  451. """Verify empty object returned when no user is tracked."""
  452. printer = await printer_factory(name="Test Printer")
  453. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  454. mock_pm.get_current_print_user.return_value = None
  455. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  456. assert response.status_code == 200
  457. assert response.json() == {}
  458. @pytest.mark.asyncio
  459. @pytest.mark.integration
  460. async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):
  461. """Verify user info is returned when tracked."""
  462. printer = await printer_factory(name="Test Printer")
  463. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  464. mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser"}
  465. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  466. assert response.status_code == 200
  467. result = response.json()
  468. assert result["user_id"] == 42
  469. assert result["username"] == "testuser"
  470. class TestPrintControlAPI:
  471. """Integration tests for print control endpoints (stop, pause, resume)."""
  472. # ========================================================================
  473. # Stop print endpoint
  474. # ========================================================================
  475. @pytest.mark.asyncio
  476. @pytest.mark.integration
  477. async def test_stop_print_not_found(self, async_client: AsyncClient):
  478. """Verify 404 for non-existent printer."""
  479. response = await async_client.post("/api/v1/printers/99999/print/stop")
  480. assert response.status_code == 404
  481. @pytest.mark.asyncio
  482. @pytest.mark.integration
  483. async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):
  484. """Verify error when printer is not connected."""
  485. printer = await printer_factory(name="Disconnected Printer")
  486. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  487. mock_pm.get_client.return_value = None
  488. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  489. assert response.status_code == 400
  490. assert "not connected" in response.json()["detail"].lower()
  491. @pytest.mark.asyncio
  492. @pytest.mark.integration
  493. async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):
  494. """Verify successful stop print request."""
  495. printer = await printer_factory(name="Printing Printer")
  496. mock_client = MagicMock()
  497. mock_client.stop_print.return_value = True
  498. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  499. mock_pm.get_client.return_value = mock_client
  500. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  501. assert response.status_code == 200
  502. assert response.json()["success"] is True
  503. mock_client.stop_print.assert_called_once()
  504. # ========================================================================
  505. # Pause print endpoint
  506. # ========================================================================
  507. @pytest.mark.asyncio
  508. @pytest.mark.integration
  509. async def test_pause_print_not_found(self, async_client: AsyncClient):
  510. """Verify 404 for non-existent printer."""
  511. response = await async_client.post("/api/v1/printers/99999/print/pause")
  512. assert response.status_code == 404
  513. @pytest.mark.asyncio
  514. @pytest.mark.integration
  515. async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):
  516. """Verify error when printer is not connected."""
  517. printer = await printer_factory(name="Disconnected Printer")
  518. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  519. mock_pm.get_client.return_value = None
  520. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  521. assert response.status_code == 400
  522. assert "not connected" in response.json()["detail"].lower()
  523. @pytest.mark.asyncio
  524. @pytest.mark.integration
  525. async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):
  526. """Verify successful pause print request."""
  527. printer = await printer_factory(name="Printing Printer")
  528. mock_client = MagicMock()
  529. mock_client.pause_print.return_value = True
  530. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  531. mock_pm.get_client.return_value = mock_client
  532. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  533. assert response.status_code == 200
  534. assert response.json()["success"] is True
  535. mock_client.pause_print.assert_called_once()
  536. # ========================================================================
  537. # Resume print endpoint
  538. # ========================================================================
  539. @pytest.mark.asyncio
  540. @pytest.mark.integration
  541. async def test_resume_print_not_found(self, async_client: AsyncClient):
  542. """Verify 404 for non-existent printer."""
  543. response = await async_client.post("/api/v1/printers/99999/print/resume")
  544. assert response.status_code == 404
  545. @pytest.mark.asyncio
  546. @pytest.mark.integration
  547. async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):
  548. """Verify error when printer is not connected."""
  549. printer = await printer_factory(name="Disconnected Printer")
  550. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  551. mock_pm.get_client.return_value = None
  552. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  553. assert response.status_code == 400
  554. assert "not connected" in response.json()["detail"].lower()
  555. @pytest.mark.asyncio
  556. @pytest.mark.integration
  557. async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):
  558. """Verify successful resume print request."""
  559. printer = await printer_factory(name="Paused Printer")
  560. mock_client = MagicMock()
  561. mock_client.resume_print.return_value = True
  562. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  563. mock_pm.get_client.return_value = mock_client
  564. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  565. assert response.status_code == 200
  566. assert response.json()["success"] is True
  567. mock_client.resume_print.assert_called_once()
  568. class TestAMSRefreshAPI:
  569. """Integration tests for AMS slot refresh endpoint."""
  570. @pytest.mark.asyncio
  571. @pytest.mark.integration
  572. async def test_ams_refresh_not_found(self, async_client: AsyncClient):
  573. """Verify 404 for non-existent printer."""
  574. response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh")
  575. assert response.status_code == 404
  576. @pytest.mark.asyncio
  577. @pytest.mark.integration
  578. async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):
  579. """Verify error when printer is not connected."""
  580. printer = await printer_factory(name="Disconnected Printer")
  581. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  582. mock_pm.get_client.return_value = None
  583. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  584. assert response.status_code == 400
  585. assert "not connected" in response.json()["detail"].lower()
  586. @pytest.mark.asyncio
  587. @pytest.mark.integration
  588. async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):
  589. """Verify successful AMS refresh request."""
  590. printer = await printer_factory(name="Printer with AMS")
  591. mock_client = MagicMock()
  592. mock_client.ams_refresh_tray.return_value = (True, "Refreshing AMS 0 tray 1")
  593. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  594. mock_pm.get_client.return_value = mock_client
  595. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh")
  596. assert response.status_code == 200
  597. result = response.json()
  598. assert result["success"] is True
  599. mock_client.ams_refresh_tray.assert_called_once_with(0, 1)
  600. @pytest.mark.asyncio
  601. @pytest.mark.integration
  602. async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):
  603. """Verify error when filament is loaded (can't refresh while loaded)."""
  604. printer = await printer_factory(name="Printer with AMS")
  605. mock_client = MagicMock()
  606. mock_client.ams_refresh_tray.return_value = (False, "Please unload filament first")
  607. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  608. mock_pm.get_client.return_value = mock_client
  609. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  610. assert response.status_code == 400
  611. assert "unload" in response.json()["detail"].lower()
  612. class TestAMSLoadUnloadAPI:
  613. """Integration tests for AMS load / unload endpoints (#891)."""
  614. # ── load ─────────────────────────────────────────────────────────────────
  615. @pytest.mark.asyncio
  616. @pytest.mark.integration
  617. async def test_load_invalid_tray_id(self, async_client: AsyncClient, printer_factory):
  618. """tray_id outside {0..15, 254, 255} is rejected."""
  619. printer = await printer_factory(name="P")
  620. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=99")
  621. assert response.status_code == 400
  622. @pytest.mark.asyncio
  623. @pytest.mark.integration
  624. async def test_load_not_found(self, async_client: AsyncClient):
  625. response = await async_client.post("/api/v1/printers/99999/ams/load?tray_id=0")
  626. assert response.status_code == 404
  627. @pytest.mark.asyncio
  628. @pytest.mark.integration
  629. async def test_load_not_connected(self, async_client: AsyncClient, printer_factory):
  630. printer = await printer_factory(name="Disconnected")
  631. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  632. mock_pm.get_client.return_value = None
  633. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  634. assert response.status_code == 400
  635. assert "not connected" in response.json()["detail"].lower()
  636. @pytest.mark.asyncio
  637. @pytest.mark.integration
  638. async def test_load_ams_slot_success(self, async_client: AsyncClient, printer_factory):
  639. """tray_id=5 → AMS 1 slot 2 (1-indexed in the message)."""
  640. printer = await printer_factory(name="P")
  641. mock_client = MagicMock()
  642. mock_client.ams_load_filament.return_value = True
  643. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  644. mock_pm.get_client.return_value = mock_client
  645. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5")
  646. assert response.status_code == 200
  647. mock_client.ams_load_filament.assert_called_once_with(5)
  648. assert "AMS 1" in response.json()["message"]
  649. @pytest.mark.asyncio
  650. @pytest.mark.integration
  651. async def test_load_external_left_success(self, async_client: AsyncClient, printer_factory):
  652. """tray_id=254 → external spool / Ext-L."""
  653. printer = await printer_factory(name="P")
  654. mock_client = MagicMock()
  655. mock_client.ams_load_filament.return_value = True
  656. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  657. mock_pm.get_client.return_value = mock_client
  658. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=254")
  659. assert response.status_code == 200
  660. mock_client.ams_load_filament.assert_called_once_with(254)
  661. assert "external" in response.json()["message"].lower()
  662. @pytest.mark.asyncio
  663. @pytest.mark.integration
  664. async def test_load_external_right_success(self, async_client: AsyncClient, printer_factory):
  665. """tray_id=255 → Ext-R on dual-nozzle H2D."""
  666. printer = await printer_factory(name="H2D")
  667. mock_client = MagicMock()
  668. mock_client.ams_load_filament.return_value = True
  669. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  670. mock_pm.get_client.return_value = mock_client
  671. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=255")
  672. assert response.status_code == 200
  673. mock_client.ams_load_filament.assert_called_once_with(255)
  674. assert "Ext-R" in response.json()["message"]
  675. @pytest.mark.asyncio
  676. @pytest.mark.integration
  677. async def test_load_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  678. printer = await printer_factory(name="P")
  679. mock_client = MagicMock()
  680. mock_client.ams_load_filament.return_value = False
  681. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  682. mock_pm.get_client.return_value = mock_client
  683. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  684. assert response.status_code == 500
  685. assert "failed" in response.json()["detail"].lower()
  686. # ── unload ───────────────────────────────────────────────────────────────
  687. @pytest.mark.asyncio
  688. @pytest.mark.integration
  689. async def test_unload_not_found(self, async_client: AsyncClient):
  690. response = await async_client.post("/api/v1/printers/99999/ams/unload")
  691. assert response.status_code == 404
  692. @pytest.mark.asyncio
  693. @pytest.mark.integration
  694. async def test_unload_not_connected(self, async_client: AsyncClient, printer_factory):
  695. printer = await printer_factory(name="Disconnected")
  696. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  697. mock_pm.get_client.return_value = None
  698. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  699. assert response.status_code == 400
  700. assert "not connected" in response.json()["detail"].lower()
  701. @pytest.mark.asyncio
  702. @pytest.mark.integration
  703. async def test_unload_success(self, async_client: AsyncClient, printer_factory):
  704. printer = await printer_factory(name="P")
  705. mock_client = MagicMock()
  706. mock_client.ams_unload_filament.return_value = True
  707. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  708. mock_pm.get_client.return_value = mock_client
  709. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  710. assert response.status_code == 200
  711. mock_client.ams_unload_filament.assert_called_once_with()
  712. assert response.json()["success"] is True
  713. @pytest.mark.asyncio
  714. @pytest.mark.integration
  715. async def test_unload_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  716. printer = await printer_factory(name="P")
  717. mock_client = MagicMock()
  718. mock_client.ams_unload_filament.return_value = False
  719. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  720. mock_pm.get_client.return_value = mock_client
  721. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  722. assert response.status_code == 500
  723. assert "failed" in response.json()["detail"].lower()
  724. class TestConfigureAMSSlotAPI:
  725. """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
  726. @pytest.mark.asyncio
  727. @pytest.mark.integration
  728. async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
  729. """Verify error when printer is not connected."""
  730. printer = await printer_factory(name="Disconnected")
  731. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  732. mock_pm.get_client.return_value = None
  733. response = await async_client.post(
  734. f"/api/v1/printers/{printer.id}/slots/0/0/configure",
  735. params={
  736. "tray_info_idx": "GFL99",
  737. "tray_type": "PLA",
  738. "tray_sub_brands": "PLA Basic",
  739. "tray_color": "FF0000FF",
  740. "nozzle_temp_min": 190,
  741. "nozzle_temp_max": 230,
  742. },
  743. )
  744. assert response.status_code == 400
  745. assert "not connected" in response.json()["detail"].lower()
  746. @pytest.mark.asyncio
  747. @pytest.mark.integration
  748. async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
  749. """Standard Bambu GF* filament IDs are sent as-is."""
  750. printer = await printer_factory(name="H2D")
  751. mock_client = MagicMock()
  752. mock_client.ams_set_filament_setting.return_value = True
  753. mock_client.extrusion_cali_sel.return_value = True
  754. mock_client.request_status_update.return_value = True
  755. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  756. mock_pm.get_client.return_value = mock_client
  757. mock_pm.get_status.return_value = None # No existing state
  758. response = await async_client.post(
  759. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  760. params={
  761. "tray_info_idx": "GFL05",
  762. "tray_type": "PLA",
  763. "tray_sub_brands": "PLA Basic",
  764. "tray_color": "FFFFFFFF",
  765. "nozzle_temp_min": 190,
  766. "nozzle_temp_max": 230,
  767. },
  768. )
  769. assert response.status_code == 200
  770. call_kwargs = mock_client.ams_set_filament_setting.call_args
  771. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  772. @pytest.mark.asyncio
  773. @pytest.mark.integration
  774. async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
  775. """PFUS* cloud-synced custom preset IDs are sent to the printer."""
  776. printer = await printer_factory(name="H2D")
  777. mock_client = MagicMock()
  778. mock_client.ams_set_filament_setting.return_value = True
  779. mock_client.extrusion_cali_sel.return_value = True
  780. mock_client.request_status_update.return_value = True
  781. mock_status = MagicMock()
  782. mock_status.raw_data = {"ams": {"ams": []}} # No existing tray data
  783. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  784. mock_pm.get_client.return_value = mock_client
  785. mock_pm.get_status.return_value = mock_status
  786. response = await async_client.post(
  787. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  788. params={
  789. "tray_info_idx": "PFUS9ac902733670a9",
  790. "tray_type": "PLA",
  791. "tray_sub_brands": "Devil Design PLA",
  792. "tray_color": "FF0000FF",
  793. "nozzle_temp_min": 190,
  794. "nozzle_temp_max": 230,
  795. },
  796. )
  797. assert response.status_code == 200
  798. call_kwargs = mock_client.ams_set_filament_setting.call_args
  799. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  800. @pytest.mark.asyncio
  801. @pytest.mark.integration
  802. async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
  803. """Provided PFUS* preset takes priority over slot's existing preset."""
  804. printer = await printer_factory(name="H2D")
  805. mock_client = MagicMock()
  806. mock_client.ams_set_filament_setting.return_value = True
  807. mock_client.extrusion_cali_sel.return_value = True
  808. mock_client.request_status_update.return_value = True
  809. # Simulate slot already configured by slicer with cloud-synced preset
  810. mock_status = MagicMock()
  811. mock_status.raw_data = {
  812. "ams": {
  813. "ams": [
  814. {
  815. "id": 2,
  816. "tray": [
  817. {
  818. "id": 3,
  819. "tray_info_idx": "P4d64437",
  820. "tray_type": "PLA",
  821. "tray_color": "FF0000FF",
  822. }
  823. ],
  824. }
  825. ]
  826. }
  827. }
  828. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  829. mock_pm.get_client.return_value = mock_client
  830. mock_pm.get_status.return_value = mock_status
  831. response = await async_client.post(
  832. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  833. params={
  834. "tray_info_idx": "PFUS9ac902733670a9",
  835. "tray_type": "PLA",
  836. "tray_sub_brands": "Devil Design PLA",
  837. "tray_color": "FF0000FF",
  838. "nozzle_temp_min": 190,
  839. "nozzle_temp_max": 230,
  840. },
  841. )
  842. assert response.status_code == 200
  843. call_kwargs = mock_client.ams_set_filament_setting.call_args
  844. # Provided preset wins over slot's existing one
  845. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  846. @pytest.mark.asyncio
  847. @pytest.mark.integration
  848. async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
  849. """Provided PFUS* preset is used even when slot has a different material."""
  850. printer = await printer_factory(name="H2D")
  851. mock_client = MagicMock()
  852. mock_client.ams_set_filament_setting.return_value = True
  853. mock_client.extrusion_cali_sel.return_value = True
  854. mock_client.request_status_update.return_value = True
  855. # Slot currently has PETG but user is configuring PLA
  856. mock_status = MagicMock()
  857. mock_status.raw_data = {
  858. "ams": {
  859. "ams": [
  860. {
  861. "id": 2,
  862. "tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
  863. }
  864. ]
  865. }
  866. }
  867. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  868. mock_pm.get_client.return_value = mock_client
  869. mock_pm.get_status.return_value = mock_status
  870. response = await async_client.post(
  871. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  872. params={
  873. "tray_info_idx": "PFUS9ac902733670a9",
  874. "tray_type": "PLA",
  875. "tray_sub_brands": "Devil Design PLA",
  876. "tray_color": "FF0000FF",
  877. "nozzle_temp_min": 190,
  878. "nozzle_temp_max": 230,
  879. },
  880. )
  881. assert response.status_code == 200
  882. call_kwargs = mock_client.ams_set_filament_setting.call_args
  883. # Provided preset wins — slot's material is irrelevant
  884. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  885. @pytest.mark.asyncio
  886. @pytest.mark.integration
  887. async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
  888. """Empty tray_info_idx (local preset) is replaced with generic."""
  889. printer = await printer_factory(name="H2D")
  890. mock_client = MagicMock()
  891. mock_client.ams_set_filament_setting.return_value = True
  892. mock_client.extrusion_cali_sel.return_value = True
  893. mock_client.request_status_update.return_value = True
  894. mock_status = MagicMock()
  895. mock_status.raw_data = {"ams": {"ams": []}}
  896. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  897. mock_pm.get_client.return_value = mock_client
  898. mock_pm.get_status.return_value = mock_status
  899. response = await async_client.post(
  900. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  901. params={
  902. "tray_info_idx": "",
  903. "tray_type": "PETG",
  904. "tray_sub_brands": "PETG Basic",
  905. "tray_color": "FFFFFFFF",
  906. "nozzle_temp_min": 220,
  907. "nozzle_temp_max": 260,
  908. },
  909. )
  910. assert response.status_code == 200
  911. call_kwargs = mock_client.ams_set_filament_setting.call_args
  912. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  913. @pytest.mark.asyncio
  914. @pytest.mark.integration
  915. async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
  916. """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
  917. Pins the end-to-end contract the frontend #1053 fix relies on: when the
  918. user configures a slot with a custom cloud preset whose cloud detail
  919. has filament_id=null, the frontend sends the setting_id in BOTH fields
  920. and the backend must not collapse either to a generic GF* ID.
  921. """
  922. printer = await printer_factory(name="H2D")
  923. mock_client = MagicMock()
  924. mock_client.ams_set_filament_setting.return_value = True
  925. mock_client.extrusion_cali_sel.return_value = True
  926. mock_client.request_status_update.return_value = True
  927. mock_status = MagicMock()
  928. mock_status.raw_data = {"ams": {"ams": []}}
  929. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  930. mock_pm.get_client.return_value = mock_client
  931. mock_pm.get_status.return_value = mock_status
  932. response = await async_client.post(
  933. f"/api/v1/printers/{printer.id}/slots/128/0/configure",
  934. params={
  935. "tray_info_idx": "PFUSa8fb76f9733e3c",
  936. "tray_type": "ABS",
  937. "tray_sub_brands": "Sting3D ABS",
  938. "tray_color": "000000FF",
  939. "nozzle_temp_min": 240,
  940. "nozzle_temp_max": 280,
  941. "setting_id": "PFUSa8fb76f9733e3c",
  942. },
  943. )
  944. assert response.status_code == 200
  945. call_kwargs = mock_client.ams_set_filament_setting.call_args
  946. assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
  947. assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
  948. # Explicitly assert no generic-collapse happened for this HT slot.
  949. assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
  950. class TestSkipObjectsAPI:
  951. """Integration tests for skip objects endpoints."""
  952. # ========================================================================
  953. # Get printable objects endpoint
  954. # ========================================================================
  955. @pytest.mark.asyncio
  956. @pytest.mark.integration
  957. async def test_get_objects_not_found(self, async_client: AsyncClient):
  958. """Verify 404 for non-existent printer."""
  959. response = await async_client.get("/api/v1/printers/99999/print/objects")
  960. assert response.status_code == 404
  961. @pytest.mark.asyncio
  962. @pytest.mark.integration
  963. async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  964. """Verify error when printer is not connected."""
  965. printer = await printer_factory(name="Disconnected Printer")
  966. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  967. mock_pm.get_client.return_value = None
  968. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  969. assert response.status_code == 400
  970. assert "not connected" in response.json()["detail"].lower()
  971. @pytest.mark.asyncio
  972. @pytest.mark.integration
  973. async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):
  974. """Verify empty objects list when no print is active."""
  975. printer = await printer_factory(name="Idle Printer")
  976. mock_client = MagicMock()
  977. mock_client.state.printable_objects = {}
  978. mock_client.state.skipped_objects = []
  979. mock_client.state.state = "IDLE"
  980. mock_client.state.subtask_name = None # Prevent FTP download attempt
  981. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  982. mock_pm.get_client.return_value = mock_client
  983. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  984. assert response.status_code == 200
  985. result = response.json()
  986. assert result["objects"] == []
  987. assert result["total"] == 0
  988. assert result["skipped_count"] == 0
  989. assert result["is_printing"] is False
  990. @pytest.mark.asyncio
  991. @pytest.mark.integration
  992. async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):
  993. """Verify objects list when print is active."""
  994. printer = await printer_factory(name="Printing Printer")
  995. mock_client = MagicMock()
  996. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  997. mock_client.state.skipped_objects = [200]
  998. mock_client.state.state = "RUNNING"
  999. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1000. mock_pm.get_client.return_value = mock_client
  1001. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1002. assert response.status_code == 200
  1003. result = response.json()
  1004. assert result["total"] == 3
  1005. assert result["skipped_count"] == 1
  1006. assert result["is_printing"] is True
  1007. # Check objects have correct structure
  1008. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  1009. assert objects_by_id[100]["name"] == "Part A"
  1010. assert objects_by_id[100]["skipped"] is False
  1011. assert objects_by_id[200]["name"] == "Part B"
  1012. assert objects_by_id[200]["skipped"] is True
  1013. assert objects_by_id[300]["name"] == "Part C"
  1014. assert objects_by_id[300]["skipped"] is False
  1015. # ========================================================================
  1016. # Skip objects endpoint
  1017. # ========================================================================
  1018. @pytest.mark.asyncio
  1019. @pytest.mark.integration
  1020. async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):
  1021. """Verify objects list includes position data when available."""
  1022. printer = await printer_factory(name="Printing Printer")
  1023. # New format with position data
  1024. mock_client = MagicMock()
  1025. mock_client.state.printable_objects = {
  1026. 100: {"name": "Part A", "x": 50.0, "y": 100.0},
  1027. 200: {"name": "Part B", "x": 150.0, "y": 100.0},
  1028. }
  1029. mock_client.state.skipped_objects = []
  1030. mock_client.state.state = "RUNNING"
  1031. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1032. mock_pm.get_client.return_value = mock_client
  1033. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1034. assert response.status_code == 200
  1035. result = response.json()
  1036. assert result["total"] == 2
  1037. # Check objects have position data
  1038. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  1039. assert objects_by_id[100]["name"] == "Part A"
  1040. assert objects_by_id[100]["x"] == 50.0
  1041. assert objects_by_id[100]["y"] == 100.0
  1042. assert objects_by_id[200]["name"] == "Part B"
  1043. assert objects_by_id[200]["x"] == 150.0
  1044. assert objects_by_id[200]["y"] == 100.0
  1045. # ========================================================================
  1046. # Skip objects endpoint
  1047. # ========================================================================
  1048. @pytest.mark.asyncio
  1049. @pytest.mark.integration
  1050. async def test_skip_objects_not_found(self, async_client: AsyncClient):
  1051. """Verify 404 for non-existent printer."""
  1052. response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100])
  1053. assert response.status_code == 404
  1054. @pytest.mark.asyncio
  1055. @pytest.mark.integration
  1056. async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  1057. """Verify error when printer is not connected."""
  1058. printer = await printer_factory(name="Disconnected Printer")
  1059. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1060. mock_pm.get_client.return_value = None
  1061. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1062. assert response.status_code == 400
  1063. assert "not connected" in response.json()["detail"].lower()
  1064. @pytest.mark.asyncio
  1065. @pytest.mark.integration
  1066. async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):
  1067. """Verify error when no object IDs provided."""
  1068. printer = await printer_factory(name="Printing Printer")
  1069. mock_client = MagicMock()
  1070. mock_client.state.printable_objects = {100: "Part A"}
  1071. mock_client.state.skipped_objects = []
  1072. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1073. mock_pm.get_client.return_value = mock_client
  1074. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[])
  1075. assert response.status_code == 400
  1076. assert "no object" in response.json()["detail"].lower()
  1077. @pytest.mark.asyncio
  1078. @pytest.mark.integration
  1079. async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):
  1080. """Verify error when object ID doesn't exist."""
  1081. printer = await printer_factory(name="Printing Printer")
  1082. mock_client = MagicMock()
  1083. mock_client.state.printable_objects = {100: "Part A"}
  1084. mock_client.state.skipped_objects = []
  1085. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1086. mock_pm.get_client.return_value = mock_client
  1087. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999])
  1088. assert response.status_code == 400
  1089. assert "invalid" in response.json()["detail"].lower()
  1090. @pytest.mark.asyncio
  1091. @pytest.mark.integration
  1092. async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):
  1093. """Verify successful skip objects request."""
  1094. printer = await printer_factory(name="Printing Printer")
  1095. mock_client = MagicMock()
  1096. mock_client.state.printable_objects = {100: "Part A", 200: "Part B"}
  1097. mock_client.state.skipped_objects = []
  1098. mock_client.skip_objects.return_value = True
  1099. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1100. mock_pm.get_client.return_value = mock_client
  1101. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1102. assert response.status_code == 200
  1103. result = response.json()
  1104. assert result["success"] is True
  1105. assert 100 in result["skipped_objects"]
  1106. mock_client.skip_objects.assert_called_once_with([100])
  1107. @pytest.mark.asyncio
  1108. @pytest.mark.integration
  1109. async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):
  1110. """Verify skipping multiple objects at once."""
  1111. printer = await printer_factory(name="Printing Printer")
  1112. mock_client = MagicMock()
  1113. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  1114. mock_client.state.skipped_objects = []
  1115. mock_client.skip_objects.return_value = True
  1116. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1117. mock_pm.get_client.return_value = mock_client
  1118. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200])
  1119. assert response.status_code == 200
  1120. result = response.json()
  1121. assert result["success"] is True
  1122. assert 100 in result["skipped_objects"]
  1123. assert 200 in result["skipped_objects"]
  1124. mock_client.skip_objects.assert_called_once_with([100, 200])
  1125. class TestChamberLightAPI:
  1126. """Integration tests for chamber light control endpoint."""
  1127. @pytest.mark.asyncio
  1128. @pytest.mark.integration
  1129. async def test_chamber_light_not_found(self, async_client: AsyncClient):
  1130. """Verify 404 for non-existent printer."""
  1131. response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true")
  1132. assert response.status_code == 404
  1133. @pytest.mark.asyncio
  1134. @pytest.mark.integration
  1135. async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):
  1136. """Verify error when printer is not connected."""
  1137. printer = await printer_factory(name="Disconnected Printer")
  1138. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1139. mock_pm.get_client.return_value = None
  1140. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1141. assert response.status_code == 400
  1142. assert "not connected" in response.json()["detail"].lower()
  1143. @pytest.mark.asyncio
  1144. @pytest.mark.integration
  1145. async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):
  1146. """Verify successful chamber light on request."""
  1147. printer = await printer_factory(name="Test Printer")
  1148. mock_client = MagicMock()
  1149. mock_client.set_chamber_light.return_value = True
  1150. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1151. mock_pm.get_client.return_value = mock_client
  1152. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1153. assert response.status_code == 200
  1154. result = response.json()
  1155. assert result["success"] is True
  1156. assert "on" in result["message"].lower()
  1157. mock_client.set_chamber_light.assert_called_once_with(True)
  1158. @pytest.mark.asyncio
  1159. @pytest.mark.integration
  1160. async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):
  1161. """Verify successful chamber light off request."""
  1162. printer = await printer_factory(name="Test Printer")
  1163. mock_client = MagicMock()
  1164. mock_client.set_chamber_light.return_value = True
  1165. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1166. mock_pm.get_client.return_value = mock_client
  1167. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false")
  1168. assert response.status_code == 200
  1169. result = response.json()
  1170. assert result["success"] is True
  1171. assert "off" in result["message"].lower()
  1172. mock_client.set_chamber_light.assert_called_once_with(False)
  1173. @pytest.mark.asyncio
  1174. @pytest.mark.integration
  1175. async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):
  1176. """Verify error handling when chamber light control fails."""
  1177. printer = await printer_factory(name="Test Printer")
  1178. mock_client = MagicMock()
  1179. mock_client.set_chamber_light.return_value = False
  1180. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1181. mock_pm.get_client.return_value = mock_client
  1182. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1183. assert response.status_code == 500
  1184. assert "failed" in response.json()["detail"].lower()
  1185. class TestAirductModeAPI:
  1186. """Integration tests for the airduct mode endpoint (P2S/H2*)."""
  1187. @pytest.mark.asyncio
  1188. @pytest.mark.integration
  1189. async def test_invalid_mode_rejected(self, async_client: AsyncClient, printer_factory):
  1190. printer = await printer_factory(name="P", model="P2S")
  1191. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=foo")
  1192. assert response.status_code == 400
  1193. @pytest.mark.asyncio
  1194. @pytest.mark.integration
  1195. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  1196. printer = await printer_factory(name="P", model="P2S")
  1197. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1198. mock_pm.get_client.return_value = None
  1199. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1200. assert response.status_code == 400
  1201. @pytest.mark.asyncio
  1202. @pytest.mark.integration
  1203. async def test_cooling_success(self, async_client: AsyncClient, printer_factory):
  1204. printer = await printer_factory(name="P", model="P2S")
  1205. mock_client = MagicMock()
  1206. mock_client.set_airduct_mode.return_value = True
  1207. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1208. mock_pm.get_client.return_value = mock_client
  1209. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1210. assert response.status_code == 200
  1211. assert response.json()["success"] is True
  1212. mock_client.set_airduct_mode.assert_called_once_with("cooling")
  1213. @pytest.mark.asyncio
  1214. @pytest.mark.integration
  1215. async def test_heating_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  1216. printer = await printer_factory(name="P", model="P2S")
  1217. mock_client = MagicMock()
  1218. mock_client.set_airduct_mode.return_value = False
  1219. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1220. mock_pm.get_client.return_value = mock_client
  1221. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=heating")
  1222. assert response.status_code == 500
  1223. class TestClearHMSErrorsAPI:
  1224. """Integration tests for clear HMS errors endpoint."""
  1225. @pytest.mark.asyncio
  1226. @pytest.mark.integration
  1227. async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
  1228. """Verify 404 for non-existent printer."""
  1229. response = await async_client.post("/api/v1/printers/99999/hms/clear")
  1230. assert response.status_code == 404
  1231. @pytest.mark.asyncio
  1232. @pytest.mark.integration
  1233. async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
  1234. """Verify error when printer is not connected."""
  1235. printer = await printer_factory(name="Disconnected Printer")
  1236. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1237. mock_pm.get_client.return_value = None
  1238. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1239. assert response.status_code == 400
  1240. assert "not connected" in response.json()["detail"].lower()
  1241. @pytest.mark.asyncio
  1242. @pytest.mark.integration
  1243. async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
  1244. """Verify successful clear HMS errors request."""
  1245. printer = await printer_factory(name="Test Printer")
  1246. mock_client = MagicMock()
  1247. mock_client.clear_hms_errors.return_value = True
  1248. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1249. mock_pm.get_client.return_value = mock_client
  1250. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1251. assert response.status_code == 200
  1252. result = response.json()
  1253. assert result["success"] is True
  1254. assert "cleared" in result["message"].lower()
  1255. mock_client.clear_hms_errors.assert_called_once()
  1256. @pytest.mark.asyncio
  1257. @pytest.mark.integration
  1258. async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
  1259. """Verify error handling when clear HMS errors fails."""
  1260. printer = await printer_factory(name="Test Printer")
  1261. mock_client = MagicMock()
  1262. mock_client.clear_hms_errors.return_value = False
  1263. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1264. mock_pm.get_client.return_value = mock_client
  1265. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1266. assert response.status_code == 500
  1267. assert "failed" in response.json()["detail"].lower()
  1268. def _build_h2d_state(*, ams_id: int = 0, tray_id: int = 2, cali_idx: int = 5):
  1269. """Build a MagicMock PrinterState for an H2D printer with a single BL spool tray.
  1270. Used by both TestApplyPaAfterRefresh (Phase 13 P13-T-BE-1) and the K-profile
  1271. persistence tests below. The tray data passes is_bambu_tag (32-char non-zero
  1272. tray_uuid + non-empty tray_info_idx).
  1273. """
  1274. nozzle = MagicMock(nozzle_diameter="0.4")
  1275. state = MagicMock()
  1276. state.nozzles = [nozzle]
  1277. state.ams_extruder_map = {"0": 0}
  1278. state.raw_data = {
  1279. "ams": [
  1280. {
  1281. "id": ams_id,
  1282. "tray": [
  1283. {
  1284. "id": tray_id,
  1285. "tray_type": "PLA",
  1286. "tag_uid": "AABBCC1122334400",
  1287. "tray_uuid": "11223344556677880011223344556677",
  1288. "tray_info_idx": "GFL05",
  1289. "cali_idx": cali_idx,
  1290. }
  1291. ],
  1292. }
  1293. ]
  1294. }
  1295. return state
  1296. def _patch_async_session_to(db_session):
  1297. """Patch backend.app.core.database.async_session so calls inside the function
  1298. under test reuse the test fixture's db_session.
  1299. `_apply_pa_after_refresh` lazy-imports `from backend.app.core.database import
  1300. async_session` at runtime (line 2849). When we patch the source module
  1301. before the call, the lazy import picks up the patched object.
  1302. Returns the patch context manager; use as `with _patch_async_session_to(db_session):`.
  1303. Pattern verified against test_print_lifecycle.py:38-42.
  1304. """
  1305. cm = AsyncMock()
  1306. cm.__aenter__ = AsyncMock(return_value=db_session)
  1307. cm.__aexit__ = AsyncMock(return_value=None)
  1308. return patch("backend.app.core.database.async_session", return_value=cm)
  1309. class TestApplyPaAfterRefresh:
  1310. """Phase 13 P13-T-BE-1: _apply_pa_after_refresh K-profile cascade.
  1311. Verifies the 3-stage cascade (local SpoolKProfile → Spoolman SpoolmanKProfile
  1312. → live tray.cali_idx fallback) and the Bug A regression (kp.extruder, not
  1313. kp.extruder_id, after the Phase 13 P13-2a fix).
  1314. `_apply_pa_after_refresh` is a free function spawned via asyncio.create_task
  1315. from the /ams-refresh endpoint. Tests call it directly because awaiting the
  1316. spawned task in an HTTP test would require sleeping past the 5-second guard
  1317. that delays MQTT until RFID re-read finishes.
  1318. """
  1319. @pytest.mark.asyncio
  1320. @pytest.mark.integration
  1321. async def test_local_kp_match_sends_stored_cali_idx(self, db_session, printer_factory):
  1322. """Local SpoolAssignment + matching SpoolKProfile → stored cali_idx wins."""
  1323. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1324. from backend.app.models.spool import Spool
  1325. from backend.app.models.spool_assignment import SpoolAssignment
  1326. from backend.app.models.spool_k_profile import SpoolKProfile
  1327. printer = await printer_factory()
  1328. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  1329. db_session.add(spool)
  1330. await db_session.flush()
  1331. db_session.add(
  1332. SpoolAssignment(
  1333. spool_id=spool.id,
  1334. printer_id=printer.id,
  1335. ams_id=0,
  1336. tray_id=2,
  1337. )
  1338. )
  1339. db_session.add(
  1340. SpoolKProfile(
  1341. spool_id=spool.id,
  1342. printer_id=printer.id,
  1343. extruder=0,
  1344. nozzle_diameter="0.4",
  1345. k_value=0.025,
  1346. cali_idx=42,
  1347. )
  1348. )
  1349. await db_session.commit()
  1350. mock_client = MagicMock()
  1351. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1352. state = _build_h2d_state(cali_idx=5)
  1353. with (
  1354. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1355. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1356. _patch_async_session_to(db_session),
  1357. ):
  1358. mock_pm.get_client.return_value = mock_client
  1359. mock_pm.get_status.return_value = state
  1360. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1361. mock_client.extrusion_cali_sel.assert_called_once()
  1362. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1363. assert kwargs["cali_idx"] == 42 # stored profile, not 5 (live)
  1364. @pytest.mark.asyncio
  1365. @pytest.mark.integration
  1366. async def test_local_no_kp_uses_live_cali_idx(self, db_session, printer_factory):
  1367. """Local SpoolAssignment but no matching SpoolKProfile → live cali_idx (Stage 3)."""
  1368. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1369. from backend.app.models.spool import Spool
  1370. from backend.app.models.spool_assignment import SpoolAssignment
  1371. printer = await printer_factory()
  1372. spool = Spool(material="PLA")
  1373. db_session.add(spool)
  1374. await db_session.flush()
  1375. db_session.add(
  1376. SpoolAssignment(
  1377. spool_id=spool.id,
  1378. printer_id=printer.id,
  1379. ams_id=0,
  1380. tray_id=2,
  1381. )
  1382. )
  1383. await db_session.commit()
  1384. mock_client = MagicMock()
  1385. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1386. state = _build_h2d_state(cali_idx=5)
  1387. with (
  1388. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1389. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1390. _patch_async_session_to(db_session),
  1391. ):
  1392. mock_pm.get_client.return_value = mock_client
  1393. mock_pm.get_status.return_value = state
  1394. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1395. mock_client.extrusion_cali_sel.assert_called_once()
  1396. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1397. assert kwargs["cali_idx"] == 5 # live tray.cali_idx fallback
  1398. @pytest.mark.asyncio
  1399. @pytest.mark.integration
  1400. async def test_spoolman_kp_when_no_local(self, db_session, printer_factory):
  1401. """No local assignment + Spoolman SlotAssignment + SpoolmanKProfile → Spoolman cali_idx."""
  1402. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1403. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  1404. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1405. printer = await printer_factory()
  1406. db_session.add(
  1407. SpoolmanSlotAssignment(
  1408. printer_id=printer.id,
  1409. ams_id=0,
  1410. tray_id=2,
  1411. spoolman_spool_id=99,
  1412. )
  1413. )
  1414. db_session.add(
  1415. SpoolmanKProfile(
  1416. spoolman_spool_id=99,
  1417. printer_id=printer.id,
  1418. extruder=0,
  1419. nozzle_diameter="0.4",
  1420. k_value=0.030,
  1421. cali_idx=77,
  1422. )
  1423. )
  1424. await db_session.commit()
  1425. mock_client = MagicMock()
  1426. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1427. state = _build_h2d_state(cali_idx=5)
  1428. with (
  1429. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1430. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1431. _patch_async_session_to(db_session),
  1432. ):
  1433. mock_pm.get_client.return_value = mock_client
  1434. mock_pm.get_status.return_value = state
  1435. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1436. mock_client.extrusion_cali_sel.assert_called_once()
  1437. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1438. assert kwargs["cali_idx"] == 77 # Spoolman stored profile
  1439. @pytest.mark.asyncio
  1440. @pytest.mark.integration
  1441. async def test_spoolman_no_kp_uses_live(self, db_session, printer_factory):
  1442. """Spoolman SlotAssignment but no SpoolmanKProfile → live cali_idx (Stage 3)."""
  1443. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1444. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1445. printer = await printer_factory()
  1446. db_session.add(
  1447. SpoolmanSlotAssignment(
  1448. printer_id=printer.id,
  1449. ams_id=0,
  1450. tray_id=2,
  1451. spoolman_spool_id=99,
  1452. )
  1453. )
  1454. await db_session.commit()
  1455. mock_client = MagicMock()
  1456. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1457. state = _build_h2d_state(cali_idx=5)
  1458. with (
  1459. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1460. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1461. _patch_async_session_to(db_session),
  1462. ):
  1463. mock_pm.get_client.return_value = mock_client
  1464. mock_pm.get_status.return_value = state
  1465. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1466. mock_client.extrusion_cali_sel.assert_called_once()
  1467. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  1468. @pytest.mark.asyncio
  1469. @pytest.mark.integration
  1470. async def test_no_assignment_uses_live(self, db_session, printer_factory):
  1471. """No assignment of any kind + live cali_idx >= 0 → live fallback."""
  1472. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1473. printer = await printer_factory()
  1474. mock_client = MagicMock()
  1475. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1476. state = _build_h2d_state(cali_idx=5)
  1477. with (
  1478. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1479. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1480. _patch_async_session_to(db_session),
  1481. ):
  1482. mock_pm.get_client.return_value = mock_client
  1483. mock_pm.get_status.return_value = state
  1484. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1485. mock_client.extrusion_cali_sel.assert_called_once()
  1486. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  1487. @pytest.mark.asyncio
  1488. @pytest.mark.integration
  1489. async def test_negative_live_cali_idx_skipped(self, db_session, printer_factory):
  1490. """No assignment + live cali_idx=-1 → no MQTT call (invalid live value)."""
  1491. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1492. printer = await printer_factory()
  1493. mock_client = MagicMock()
  1494. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1495. state = _build_h2d_state(cali_idx=-1)
  1496. with (
  1497. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1498. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1499. _patch_async_session_to(db_session),
  1500. ):
  1501. mock_pm.get_client.return_value = mock_client
  1502. mock_pm.get_status.return_value = state
  1503. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1504. mock_client.extrusion_cali_sel.assert_not_called()
  1505. @pytest.mark.asyncio
  1506. @pytest.mark.integration
  1507. async def test_no_assignment_no_live_cali_idx_no_call(self, db_session, printer_factory):
  1508. """No assignment of any kind AND no live cali_idx in tray → no MQTT call.
  1509. Distinct from test_negative_live_cali_idx_skipped: that test has
  1510. cali_idx=-1 in raw_data; this one omits the field entirely (returns
  1511. None from .get("cali_idx")). Both must result in no MQTT call.
  1512. """
  1513. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1514. printer = await printer_factory()
  1515. mock_client = MagicMock()
  1516. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1517. # State with NO cali_idx field on tray at all
  1518. nozzle = MagicMock(nozzle_diameter="0.4")
  1519. state = MagicMock()
  1520. state.nozzles = [nozzle]
  1521. state.ams_extruder_map = {"0": 0}
  1522. state.raw_data = {
  1523. "ams": [
  1524. {
  1525. "id": 0,
  1526. "tray": [
  1527. {
  1528. "id": 2,
  1529. "tray_type": "PLA",
  1530. "tag_uid": "AABBCC1122334400",
  1531. "tray_uuid": "11223344556677880011223344556677",
  1532. "tray_info_idx": "GFL05",
  1533. # cali_idx field intentionally omitted
  1534. }
  1535. ],
  1536. }
  1537. ]
  1538. }
  1539. with (
  1540. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1541. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1542. _patch_async_session_to(db_session),
  1543. ):
  1544. mock_pm.get_client.return_value = mock_client
  1545. mock_pm.get_status.return_value = state
  1546. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1547. mock_client.extrusion_cali_sel.assert_not_called()
  1548. @pytest.mark.asyncio
  1549. @pytest.mark.integration
  1550. async def test_extruder_mismatch_uses_kp_as_fallback(self, db_session, printer_factory):
  1551. """K-profile for extruder=1 but slot is extruder=0 → no exact match,
  1552. but the kp is used as extruder-agnostic fallback rather than dropped.
  1553. Hard-skipping on extruder mismatch was the previous behavior; in
  1554. practice it caused stored K-profiles to be silently ignored whenever
  1555. the AMS-extruder mapping had shifted (or when only one of the two
  1556. extruders was ever calibrated for a given spool). The cascade now
  1557. prefers an exact extruder match but falls back to any matching kp
  1558. for the same printer + nozzle.
  1559. """
  1560. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1561. from backend.app.models.spool import Spool
  1562. from backend.app.models.spool_assignment import SpoolAssignment
  1563. from backend.app.models.spool_k_profile import SpoolKProfile
  1564. printer = await printer_factory()
  1565. spool = Spool(material="PLA")
  1566. db_session.add(spool)
  1567. await db_session.flush()
  1568. db_session.add(
  1569. SpoolAssignment(
  1570. spool_id=spool.id,
  1571. printer_id=printer.id,
  1572. ams_id=0,
  1573. tray_id=2,
  1574. )
  1575. )
  1576. # K-profile is for extruder=1, but slot's ams_extruder_map["0"]=0
  1577. db_session.add(
  1578. SpoolKProfile(
  1579. spool_id=spool.id,
  1580. printer_id=printer.id,
  1581. extruder=1,
  1582. nozzle_diameter="0.4",
  1583. k_value=0.025,
  1584. cali_idx=42,
  1585. )
  1586. )
  1587. await db_session.commit()
  1588. mock_client = MagicMock()
  1589. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1590. state = _build_h2d_state(cali_idx=5)
  1591. with (
  1592. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1593. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1594. _patch_async_session_to(db_session),
  1595. ):
  1596. mock_pm.get_client.return_value = mock_client
  1597. mock_pm.get_status.return_value = state
  1598. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1599. # No exact extruder match, but the stored kp wins as the
  1600. # extruder-agnostic fallback over live cali_idx=5.
  1601. mock_client.extrusion_cali_sel.assert_called_once()
  1602. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1603. @pytest.mark.asyncio
  1604. @pytest.mark.integration
  1605. async def test_extruder_exact_match_preferred_over_fallback(
  1606. self,
  1607. db_session,
  1608. printer_factory,
  1609. ):
  1610. """When two kp rows exist, one with matching extruder and one without,
  1611. the exact-extruder kp wins (extruder-agnostic fallback only fires when
  1612. no exact match exists).
  1613. """
  1614. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1615. from backend.app.models.spool import Spool
  1616. from backend.app.models.spool_assignment import SpoolAssignment
  1617. from backend.app.models.spool_k_profile import SpoolKProfile
  1618. printer = await printer_factory()
  1619. spool = Spool(material="PLA")
  1620. db_session.add(spool)
  1621. await db_session.flush()
  1622. db_session.add(
  1623. SpoolAssignment(
  1624. spool_id=spool.id,
  1625. printer_id=printer.id,
  1626. ams_id=0,
  1627. tray_id=2,
  1628. )
  1629. )
  1630. # Two kp rows: extruder=1 (mismatch w/ slot extruder=0) and extruder=0 (exact)
  1631. db_session.add(
  1632. SpoolKProfile(
  1633. spool_id=spool.id,
  1634. printer_id=printer.id,
  1635. extruder=1,
  1636. nozzle_diameter="0.4",
  1637. k_value=0.030,
  1638. cali_idx=99,
  1639. )
  1640. )
  1641. db_session.add(
  1642. SpoolKProfile(
  1643. spool_id=spool.id,
  1644. printer_id=printer.id,
  1645. extruder=0,
  1646. nozzle_diameter="0.4",
  1647. k_value=0.025,
  1648. cali_idx=42,
  1649. )
  1650. )
  1651. await db_session.commit()
  1652. mock_client = MagicMock()
  1653. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1654. state = _build_h2d_state(cali_idx=5)
  1655. with (
  1656. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1657. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1658. _patch_async_session_to(db_session),
  1659. ):
  1660. mock_pm.get_client.return_value = mock_client
  1661. mock_pm.get_status.return_value = state
  1662. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1663. # Exact-extruder=0 kp wins (cali_idx=42), not the extruder=1 fallback (99)
  1664. mock_client.extrusion_cali_sel.assert_called_once()
  1665. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1666. @pytest.mark.asyncio
  1667. @pytest.mark.integration
  1668. async def test_regression_bug_a_kp_extruder_attr(self, db_session, printer_factory):
  1669. """Regression test for Phase 13 P13-2a Bug A.
  1670. Pre-fix Z.2910 used `kp.extruder_id` (AttributeError on SpoolKProfile,
  1671. silently swallowed by outer try/except). On dual-nozzle printers with
  1672. slot_extruder != None this caused the K-profile match loop to crash.
  1673. After P13-2a the field name is correct: `kp.extruder`.
  1674. """
  1675. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1676. from backend.app.models.spool import Spool
  1677. from backend.app.models.spool_assignment import SpoolAssignment
  1678. from backend.app.models.spool_k_profile import SpoolKProfile
  1679. printer = await printer_factory()
  1680. spool = Spool(material="PLA")
  1681. db_session.add(spool)
  1682. await db_session.flush()
  1683. db_session.add(
  1684. SpoolAssignment(
  1685. spool_id=spool.id,
  1686. printer_id=printer.id,
  1687. ams_id=0,
  1688. tray_id=2,
  1689. )
  1690. )
  1691. # extruder=0 matches slot_extruder=0 (from ams_extruder_map={"0":0})
  1692. db_session.add(
  1693. SpoolKProfile(
  1694. spool_id=spool.id,
  1695. printer_id=printer.id,
  1696. extruder=0,
  1697. nozzle_diameter="0.4",
  1698. k_value=0.025,
  1699. cali_idx=42,
  1700. )
  1701. )
  1702. await db_session.commit()
  1703. mock_client = MagicMock()
  1704. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1705. state = _build_h2d_state(cali_idx=5)
  1706. with (
  1707. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1708. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1709. _patch_async_session_to(db_session),
  1710. ):
  1711. mock_pm.get_client.return_value = mock_client
  1712. mock_pm.get_status.return_value = state
  1713. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1714. # If Bug A regressed (kp.extruder_id), the loop would AttributeError → silent fail
  1715. # → no extrusion_cali_sel call. Post-fix the loop matches and sends cali_idx=42.
  1716. mock_client.extrusion_cali_sel.assert_called_once()
  1717. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1718. @pytest.mark.asyncio
  1719. @pytest.mark.integration
  1720. async def test_tag_fallback_finds_spool_when_assignment_missing(
  1721. self,
  1722. db_session,
  1723. printer_factory,
  1724. ):
  1725. """Stage 1b regression for the maintainer's #2 reproducer on H2D:
  1726. reset slot, trigger re-read → slot ends up on the default K-profile
  1727. instead of the spool's stored profile.
  1728. Setup mirrors the bug:
  1729. - Spool has tray_uuid set (the RFID tag was registered earlier).
  1730. - SpoolKProfile exists for that spool with cali_idx=42.
  1731. - NO SpoolAssignment row — the reset deleted it before the re-read
  1732. triggered _apply_pa_after_refresh, and tag-auto-detect has not
  1733. re-created it yet within the 5 s sleep window.
  1734. - Live tray.cali_idx=5 (firmware-default after the RFID re-read).
  1735. Without Stage 1b the cascade falls through to Stage 3 and re-asserts
  1736. the firmware-default cali_idx=5. With Stage 1b it locates the spool by
  1737. the live tray's tray_uuid and applies the stored cali_idx=42.
  1738. """
  1739. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1740. from backend.app.models.spool import Spool
  1741. from backend.app.models.spool_k_profile import SpoolKProfile
  1742. printer = await printer_factory()
  1743. # Spool with tray_uuid matching the one _build_h2d_state puts on the tray
  1744. spool = Spool(
  1745. material="PLA",
  1746. color_name="Red",
  1747. rgba="FF0000FF",
  1748. tray_uuid="11223344556677880011223344556677",
  1749. tag_uid="AABBCC1122334400",
  1750. )
  1751. db_session.add(spool)
  1752. await db_session.flush()
  1753. # K-profile is bound to the spool, not to a slot
  1754. db_session.add(
  1755. SpoolKProfile(
  1756. spool_id=spool.id,
  1757. printer_id=printer.id,
  1758. extruder=0,
  1759. nozzle_diameter="0.4",
  1760. k_value=0.025,
  1761. cali_idx=42,
  1762. )
  1763. )
  1764. # NOTE: deliberately no SpoolAssignment — that's the bug condition.
  1765. await db_session.commit()
  1766. mock_client = MagicMock()
  1767. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1768. state = _build_h2d_state(cali_idx=5)
  1769. with (
  1770. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1771. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1772. _patch_async_session_to(db_session),
  1773. ):
  1774. mock_pm.get_client.return_value = mock_client
  1775. mock_pm.get_status.return_value = state
  1776. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1777. # Stage 1b should match the spool by tray_uuid → stored cali_idx=42 wins
  1778. # over live cali_idx=5. Pre-fix this would have been 5 (firmware default).
  1779. mock_client.extrusion_cali_sel.assert_called_once()
  1780. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1781. @pytest.mark.asyncio
  1782. @pytest.mark.integration
  1783. async def test_tag_fallback_matches_by_tag_uid_when_uuid_zero(
  1784. self,
  1785. db_session,
  1786. printer_factory,
  1787. ):
  1788. """Stage 1b: when tray_uuid is the zero sentinel but tag_uid is real,
  1789. match by tag_uid. Older firmwares occasionally report a zero tray_uuid
  1790. right after RFID re-read while the tag_uid is already populated."""
  1791. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1792. from backend.app.models.spool import Spool
  1793. from backend.app.models.spool_k_profile import SpoolKProfile
  1794. printer = await printer_factory()
  1795. # Spool indexed by tag_uid, not tray_uuid
  1796. spool = Spool(material="PLA", tag_uid="AABBCC1122334400")
  1797. db_session.add(spool)
  1798. await db_session.flush()
  1799. db_session.add(
  1800. SpoolKProfile(
  1801. spool_id=spool.id,
  1802. printer_id=printer.id,
  1803. extruder=0,
  1804. nozzle_diameter="0.4",
  1805. k_value=0.025,
  1806. cali_idx=99,
  1807. )
  1808. )
  1809. await db_session.commit()
  1810. mock_client = MagicMock()
  1811. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1812. # Build a state where the tray reports a real tag_uid but a zero tray_uuid
  1813. # while still passing is_bambu_tag (tag_uid + tray_info_idx is sufficient).
  1814. state = _build_h2d_state(cali_idx=5)
  1815. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  1816. with (
  1817. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1818. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1819. _patch_async_session_to(db_session),
  1820. ):
  1821. mock_pm.get_client.return_value = mock_client
  1822. mock_pm.get_status.return_value = state
  1823. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1824. mock_client.extrusion_cali_sel.assert_called_once()
  1825. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 99
  1826. @pytest.mark.asyncio
  1827. @pytest.mark.integration
  1828. async def test_tag_fallback_skipped_when_zero_sentinels(
  1829. self,
  1830. db_session,
  1831. printer_factory,
  1832. ):
  1833. """Stage 1b: when both tray_uuid and tag_uid are zero sentinels, the
  1834. fallback must not match any spool (would otherwise pick up an
  1835. unrelated spool created with empty/zero tag fields). Falls through
  1836. to Stage 3 live cali_idx as before.
  1837. """
  1838. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1839. from backend.app.models.spool import Spool
  1840. from backend.app.models.spool_k_profile import SpoolKProfile
  1841. printer = await printer_factory()
  1842. # Decoy spool with no tag info — must NOT match
  1843. spool = Spool(material="PLA")
  1844. db_session.add(spool)
  1845. await db_session.flush()
  1846. db_session.add(
  1847. SpoolKProfile(
  1848. spool_id=spool.id,
  1849. printer_id=printer.id,
  1850. extruder=0,
  1851. nozzle_diameter="0.4",
  1852. k_value=0.025,
  1853. cali_idx=42,
  1854. )
  1855. )
  1856. await db_session.commit()
  1857. mock_client = MagicMock()
  1858. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1859. state = _build_h2d_state(cali_idx=7)
  1860. # Force both tag fields to the zero sentinels but keep tray_info_idx
  1861. # so is_bambu_tag still passes (preset present)
  1862. state.raw_data["ams"][0]["tray"][0]["tag_uid"] = "0000000000000000"
  1863. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  1864. with (
  1865. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1866. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1867. _patch_async_session_to(db_session),
  1868. ):
  1869. mock_pm.get_client.return_value = mock_client
  1870. mock_pm.get_status.return_value = state
  1871. # is_bambu_tag actually rejects both-zero + only-preset, so the
  1872. # function returns early. We just want to confirm we didn't blow
  1873. # up scanning for a tag-fallback spool.
  1874. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1875. # is_bambu_tag short-circuits early when both UID and UUID are zero,
  1876. # so no MQTT call should fire and the decoy spool's cali_idx=42 must
  1877. # NOT leak through.
  1878. if mock_client.extrusion_cali_sel.called:
  1879. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] != 42
  1880. class TestConfigureAmsSlotPersistsKProfile:
  1881. """Phase 13 P13-T-BE-2: configure_ams_slot persists K-profile to DB.
  1882. Pre-Phase-13 the endpoint sent extrusion_cali_sel via MQTT but never
  1883. recorded the choice in spool_k_profile / spoolman_k_profile, so the next
  1884. RFID re-read had no stored profile to apply.
  1885. """
  1886. @pytest.mark.asyncio
  1887. @pytest.mark.integration
  1888. async def test_writes_spoolman_kprofile_when_spoolman_assigned(
  1889. self,
  1890. async_client: AsyncClient,
  1891. db_session,
  1892. printer_factory,
  1893. ):
  1894. """SpoolmanSlotAssignment present → SpoolmanKProfile row created with cali_idx + k_value + name."""
  1895. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  1896. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1897. printer = await printer_factory(model="H2D")
  1898. db_session.add(
  1899. SpoolmanSlotAssignment(
  1900. printer_id=printer.id,
  1901. ams_id=0,
  1902. tray_id=3,
  1903. spoolman_spool_id=216,
  1904. )
  1905. )
  1906. await db_session.commit()
  1907. mock_client = MagicMock()
  1908. mock_client.ams_set_filament_setting.return_value = True
  1909. mock_client.extrusion_cali_sel.return_value = True
  1910. mock_client.extrusion_cali_set.return_value = True
  1911. mock_client.request_status_update.return_value = True
  1912. mock_state = MagicMock()
  1913. mock_state.ams_extruder_map = {"0": 0}
  1914. mock_state.raw_data = {"ams": {"ams": []}}
  1915. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1916. mock_pm.get_client.return_value = mock_client
  1917. mock_pm.get_status.return_value = mock_state
  1918. response = await async_client.post(
  1919. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  1920. params={
  1921. "tray_info_idx": "GFL05",
  1922. "tray_type": "PLA",
  1923. "tray_sub_brands": "Bambu PLA Metal",
  1924. "tray_color": "FF8800FF",
  1925. "nozzle_temp_min": 220,
  1926. "nozzle_temp_max": 240,
  1927. "cali_idx": 5,
  1928. "nozzle_diameter": "0.4",
  1929. "k_value": 0.022,
  1930. },
  1931. )
  1932. assert response.status_code == 200
  1933. kp_result = await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  1934. kp = kp_result.scalar_one_or_none()
  1935. assert kp is not None
  1936. assert kp.cali_idx == 5
  1937. assert kp.k_value == pytest.approx(0.022)
  1938. assert kp.extruder == 0
  1939. assert kp.nozzle_diameter == "0.4"
  1940. assert kp.name == "Bambu PLA Metal"
  1941. @pytest.mark.asyncio
  1942. @pytest.mark.integration
  1943. async def test_writes_spool_kprofile_when_local_assigned(
  1944. self,
  1945. async_client: AsyncClient,
  1946. db_session,
  1947. printer_factory,
  1948. ):
  1949. """Local SpoolAssignment present → SpoolKProfile row created."""
  1950. from backend.app.models.spool import Spool
  1951. from backend.app.models.spool_assignment import SpoolAssignment
  1952. from backend.app.models.spool_k_profile import SpoolKProfile
  1953. printer = await printer_factory(model="H2D")
  1954. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  1955. db_session.add(spool)
  1956. await db_session.flush()
  1957. db_session.add(
  1958. SpoolAssignment(
  1959. spool_id=spool.id,
  1960. printer_id=printer.id,
  1961. ams_id=0,
  1962. tray_id=3,
  1963. )
  1964. )
  1965. await db_session.commit()
  1966. spool_id = spool.id
  1967. mock_client = MagicMock()
  1968. mock_client.ams_set_filament_setting.return_value = True
  1969. mock_client.extrusion_cali_sel.return_value = True
  1970. mock_client.extrusion_cali_set.return_value = True
  1971. mock_client.request_status_update.return_value = True
  1972. mock_state = MagicMock()
  1973. mock_state.ams_extruder_map = {"0": 0}
  1974. mock_state.raw_data = {"ams": {"ams": []}}
  1975. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1976. mock_pm.get_client.return_value = mock_client
  1977. mock_pm.get_status.return_value = mock_state
  1978. response = await async_client.post(
  1979. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  1980. params={
  1981. "tray_info_idx": "PFUSdev01",
  1982. "tray_type": "PLA",
  1983. "tray_sub_brands": "Devil Design PLA",
  1984. "tray_color": "FF0000FF",
  1985. "nozzle_temp_min": 220,
  1986. "nozzle_temp_max": 240,
  1987. "cali_idx": 7,
  1988. "nozzle_diameter": "0.4",
  1989. "k_value": 0.028,
  1990. },
  1991. )
  1992. assert response.status_code == 200
  1993. kp_result = await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  1994. kp = kp_result.scalar_one_or_none()
  1995. assert kp is not None
  1996. assert kp.cali_idx == 7
  1997. assert kp.k_value == pytest.approx(0.028)
  1998. assert kp.extruder == 0
  1999. assert kp.nozzle_diameter == "0.4"
  2000. assert kp.name == "Devil Design PLA"
  2001. @pytest.mark.asyncio
  2002. @pytest.mark.integration
  2003. async def test_no_assignment_no_persist(
  2004. self,
  2005. async_client: AsyncClient,
  2006. db_session,
  2007. printer_factory,
  2008. ):
  2009. """No SpoolAssignment AND no SpoolmanSlotAssignment → no DB write, MQTT still sent."""
  2010. from backend.app.models.spool_k_profile import SpoolKProfile
  2011. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2012. printer = await printer_factory(model="H2D")
  2013. mock_client = MagicMock()
  2014. mock_client.ams_set_filament_setting.return_value = True
  2015. mock_client.extrusion_cali_sel.return_value = True
  2016. mock_client.request_status_update.return_value = True
  2017. mock_state = MagicMock()
  2018. mock_state.ams_extruder_map = {"0": 0}
  2019. mock_state.raw_data = {"ams": {"ams": []}}
  2020. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2021. mock_pm.get_client.return_value = mock_client
  2022. mock_pm.get_status.return_value = mock_state
  2023. response = await async_client.post(
  2024. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2025. params={
  2026. "tray_info_idx": "GFL05",
  2027. "tray_type": "PLA",
  2028. "tray_sub_brands": "PLA Basic",
  2029. "tray_color": "FFFFFFFF",
  2030. "nozzle_temp_min": 190,
  2031. "nozzle_temp_max": 230,
  2032. "cali_idx": 5,
  2033. "k_value": 0.020,
  2034. },
  2035. )
  2036. assert response.status_code == 200
  2037. # MQTT sent (was successful), but no DB writes
  2038. mock_client.extrusion_cali_sel.assert_called_once()
  2039. local_count = (await db_session.execute(select(SpoolKProfile))).scalars().all()
  2040. sm_count = (await db_session.execute(select(SpoolmanKProfile))).scalars().all()
  2041. assert len(local_count) == 0
  2042. assert len(sm_count) == 0
  2043. @pytest.mark.asyncio
  2044. @pytest.mark.integration
  2045. async def test_negative_cali_idx_no_persist(
  2046. self,
  2047. async_client: AsyncClient,
  2048. db_session,
  2049. printer_factory,
  2050. ):
  2051. """cali_idx=-1 (no profile selected) → no DB write even when assignment exists."""
  2052. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2053. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2054. printer = await printer_factory(model="H2D")
  2055. db_session.add(
  2056. SpoolmanSlotAssignment(
  2057. printer_id=printer.id,
  2058. ams_id=0,
  2059. tray_id=3,
  2060. spoolman_spool_id=216,
  2061. )
  2062. )
  2063. await db_session.commit()
  2064. mock_client = MagicMock()
  2065. mock_client.ams_set_filament_setting.return_value = True
  2066. mock_client.extrusion_cali_sel.return_value = True
  2067. mock_client.extrusion_cali_set.return_value = True
  2068. mock_client.request_status_update.return_value = True
  2069. mock_state = MagicMock()
  2070. mock_state.ams_extruder_map = {"0": 0}
  2071. mock_state.raw_data = {"ams": {"ams": []}}
  2072. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2073. mock_pm.get_client.return_value = mock_client
  2074. mock_pm.get_status.return_value = mock_state
  2075. response = await async_client.post(
  2076. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2077. params={
  2078. "tray_info_idx": "GFL05",
  2079. "tray_type": "PLA",
  2080. "tray_sub_brands": "PLA Basic",
  2081. "tray_color": "FFFFFFFF",
  2082. "nozzle_temp_min": 190,
  2083. "nozzle_temp_max": 230,
  2084. "cali_idx": -1,
  2085. "k_value": 0.0,
  2086. },
  2087. )
  2088. assert response.status_code == 200
  2089. sm_kps = (
  2090. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  2091. .scalars()
  2092. .all()
  2093. )
  2094. assert len(sm_kps) == 0 # cali_idx=-1 means "no profile" — don't write
  2095. @pytest.mark.asyncio
  2096. @pytest.mark.integration
  2097. async def test_zero_cali_idx_persists(
  2098. self,
  2099. async_client: AsyncClient,
  2100. db_session,
  2101. printer_factory,
  2102. ):
  2103. """cali_idx=0 is the first valid profile slot (NOT a sentinel for missing)."""
  2104. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2105. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2106. printer = await printer_factory(model="H2D")
  2107. db_session.add(
  2108. SpoolmanSlotAssignment(
  2109. printer_id=printer.id,
  2110. ams_id=0,
  2111. tray_id=3,
  2112. spoolman_spool_id=216,
  2113. )
  2114. )
  2115. await db_session.commit()
  2116. mock_client = MagicMock()
  2117. mock_client.ams_set_filament_setting.return_value = True
  2118. mock_client.extrusion_cali_sel.return_value = True
  2119. mock_client.request_status_update.return_value = True
  2120. mock_state = MagicMock()
  2121. mock_state.ams_extruder_map = {"0": 0}
  2122. mock_state.raw_data = {"ams": {"ams": []}}
  2123. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2124. mock_pm.get_client.return_value = mock_client
  2125. mock_pm.get_status.return_value = mock_state
  2126. response = await async_client.post(
  2127. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2128. params={
  2129. "tray_info_idx": "GFL05",
  2130. "tray_type": "PLA",
  2131. "tray_sub_brands": "PLA Basic",
  2132. "tray_color": "FFFFFFFF",
  2133. "nozzle_temp_min": 190,
  2134. "nozzle_temp_max": 230,
  2135. "cali_idx": 0,
  2136. "k_value": 0.020,
  2137. },
  2138. )
  2139. assert response.status_code == 200
  2140. kp = (
  2141. await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  2142. ).scalar_one_or_none()
  2143. assert kp is not None
  2144. assert kp.cali_idx == 0 # explicitly testing 0 is valid
  2145. @pytest.mark.asyncio
  2146. @pytest.mark.integration
  2147. async def test_upsert_idempotent(
  2148. self,
  2149. async_client: AsyncClient,
  2150. db_session,
  2151. printer_factory,
  2152. ):
  2153. """Repeated POSTs update the same row (UNIQUE on spool_id+printer+extruder+nozzle_diameter)."""
  2154. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2155. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2156. printer = await printer_factory(model="H2D")
  2157. db_session.add(
  2158. SpoolmanSlotAssignment(
  2159. printer_id=printer.id,
  2160. ams_id=0,
  2161. tray_id=3,
  2162. spoolman_spool_id=216,
  2163. )
  2164. )
  2165. await db_session.commit()
  2166. mock_client = MagicMock()
  2167. mock_client.ams_set_filament_setting.return_value = True
  2168. mock_client.extrusion_cali_sel.return_value = True
  2169. mock_client.request_status_update.return_value = True
  2170. mock_state = MagicMock()
  2171. mock_state.ams_extruder_map = {"0": 0}
  2172. mock_state.raw_data = {"ams": {"ams": []}}
  2173. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2174. mock_pm.get_client.return_value = mock_client
  2175. mock_pm.get_status.return_value = mock_state
  2176. # First call with cali_idx=5
  2177. await async_client.post(
  2178. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2179. params={
  2180. "tray_info_idx": "GFL05",
  2181. "tray_type": "PLA",
  2182. "tray_sub_brands": "PLA Basic",
  2183. "tray_color": "FFFFFFFF",
  2184. "nozzle_temp_min": 190,
  2185. "nozzle_temp_max": 230,
  2186. "cali_idx": 5,
  2187. "k_value": 0.020,
  2188. },
  2189. )
  2190. # Second call with cali_idx=10 (same slot/spool/extruder/nozzle)
  2191. await async_client.post(
  2192. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2193. params={
  2194. "tray_info_idx": "GFL05",
  2195. "tray_type": "PLA",
  2196. "tray_sub_brands": "PLA Matte",
  2197. "tray_color": "FFFFFFFF",
  2198. "nozzle_temp_min": 190,
  2199. "nozzle_temp_max": 230,
  2200. "cali_idx": 10,
  2201. "k_value": 0.025,
  2202. },
  2203. )
  2204. # Should be exactly ONE row (updated), not two
  2205. kps = (
  2206. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  2207. .scalars()
  2208. .all()
  2209. )
  2210. assert len(kps) == 1
  2211. assert kps[0].cali_idx == 10 # updated to most recent
  2212. assert kps[0].name == "PLA Matte"
  2213. @pytest.mark.asyncio
  2214. @pytest.mark.integration
  2215. async def test_external_slot_extruder_inversion(
  2216. self,
  2217. async_client: AsyncClient,
  2218. db_session,
  2219. printer_factory,
  2220. ):
  2221. """ams_id=255 + tray_id=0 → kp.extruder=1 (ext-L); tray_id=1 → extruder=0 (ext-R)."""
  2222. from backend.app.models.spool import Spool
  2223. from backend.app.models.spool_assignment import SpoolAssignment
  2224. from backend.app.models.spool_k_profile import SpoolKProfile
  2225. printer = await printer_factory(model="H2D")
  2226. spool = Spool(material="PLA")
  2227. db_session.add(spool)
  2228. await db_session.flush()
  2229. # Note: SpoolmanSlotAssignment can't store ams_id=255 with tray_id=1
  2230. # under the ck_tray_id_range constraint (0-3 valid). External-slot
  2231. # K-profile persistence is therefore tested via local SpoolAssignment.
  2232. db_session.add(
  2233. SpoolAssignment(
  2234. spool_id=spool.id,
  2235. printer_id=printer.id,
  2236. ams_id=255,
  2237. tray_id=0,
  2238. )
  2239. )
  2240. await db_session.commit()
  2241. spool_id = spool.id
  2242. mock_client = MagicMock()
  2243. mock_client.ams_set_filament_setting.return_value = True
  2244. mock_client.extrusion_cali_sel.return_value = True
  2245. mock_client.request_status_update.return_value = True
  2246. mock_state = MagicMock()
  2247. mock_state.ams_extruder_map = {"0": 0} # truthy so external-inversion path runs
  2248. mock_state.raw_data = {"ams": {"ams": []}}
  2249. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2250. mock_pm.get_client.return_value = mock_client
  2251. mock_pm.get_status.return_value = mock_state
  2252. response = await async_client.post(
  2253. f"/api/v1/printers/{printer.id}/slots/255/0/configure",
  2254. params={
  2255. "tray_info_idx": "GFL05",
  2256. "tray_type": "PLA",
  2257. "tray_sub_brands": "PLA Basic",
  2258. "tray_color": "FFFFFFFF",
  2259. "nozzle_temp_min": 190,
  2260. "nozzle_temp_max": 230,
  2261. "cali_idx": 5,
  2262. "k_value": 0.020,
  2263. },
  2264. )
  2265. assert response.status_code == 200
  2266. kp = (
  2267. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2268. ).scalar_one_or_none()
  2269. assert kp is not None
  2270. # tray_id=0 → extruder = 1 - 0 = 1
  2271. assert kp.extruder == 1
  2272. @pytest.mark.asyncio
  2273. @pytest.mark.integration
  2274. async def test_dual_nozzle_extruder_persists(
  2275. self,
  2276. async_client: AsyncClient,
  2277. db_session,
  2278. printer_factory,
  2279. ):
  2280. """ams_extruder_map with extruder=1 → kp.extruder=1 persisted correctly."""
  2281. from backend.app.models.spool import Spool
  2282. from backend.app.models.spool_assignment import SpoolAssignment
  2283. from backend.app.models.spool_k_profile import SpoolKProfile
  2284. printer = await printer_factory(model="H2D")
  2285. spool = Spool(material="PLA")
  2286. db_session.add(spool)
  2287. await db_session.flush()
  2288. db_session.add(
  2289. SpoolAssignment(
  2290. spool_id=spool.id,
  2291. printer_id=printer.id,
  2292. ams_id=2,
  2293. tray_id=3,
  2294. )
  2295. )
  2296. await db_session.commit()
  2297. spool_id = spool.id
  2298. mock_client = MagicMock()
  2299. mock_client.ams_set_filament_setting.return_value = True
  2300. mock_client.extrusion_cali_sel.return_value = True
  2301. mock_client.request_status_update.return_value = True
  2302. mock_state = MagicMock()
  2303. mock_state.ams_extruder_map = {"2": 1} # AMS 2 is on extruder 1
  2304. mock_state.raw_data = {"ams": {"ams": []}}
  2305. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2306. mock_pm.get_client.return_value = mock_client
  2307. mock_pm.get_status.return_value = mock_state
  2308. response = await async_client.post(
  2309. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  2310. params={
  2311. "tray_info_idx": "GFL05",
  2312. "tray_type": "PLA",
  2313. "tray_sub_brands": "PLA Basic",
  2314. "tray_color": "FFFFFFFF",
  2315. "nozzle_temp_min": 190,
  2316. "nozzle_temp_max": 230,
  2317. "cali_idx": 5,
  2318. "k_value": 0.020,
  2319. },
  2320. )
  2321. assert response.status_code == 200
  2322. kp = (
  2323. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2324. ).scalar_one_or_none()
  2325. assert kp is not None
  2326. assert kp.extruder == 1
  2327. @pytest.mark.asyncio
  2328. @pytest.mark.integration
  2329. async def test_db_error_does_not_fail_endpoint(
  2330. self,
  2331. async_client: AsyncClient,
  2332. db_session,
  2333. printer_factory,
  2334. ):
  2335. """DB errors during K-profile persistence are best-effort — endpoint still returns 200.
  2336. Verifies the try/except wrap added in P13-3b: if DB upsert fails (e.g.
  2337. because the schema is out of sync, a constraint violation, or any
  2338. other transient error), the MQTT command was already sent successfully
  2339. so we shouldn't return 500 to the user. The error is logged and the
  2340. endpoint returns success.
  2341. """
  2342. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2343. printer = await printer_factory(model="H2D")
  2344. db_session.add(
  2345. SpoolmanSlotAssignment(
  2346. printer_id=printer.id,
  2347. ams_id=0,
  2348. tray_id=3,
  2349. spoolman_spool_id=216,
  2350. )
  2351. )
  2352. await db_session.commit()
  2353. mock_client = MagicMock()
  2354. mock_client.ams_set_filament_setting.return_value = True
  2355. mock_client.extrusion_cali_sel.return_value = True
  2356. mock_client.request_status_update.return_value = True
  2357. mock_state = MagicMock()
  2358. mock_state.ams_extruder_map = {"0": 0}
  2359. mock_state.raw_data = {"ams": {"ams": []}}
  2360. # Force the K-profile persistence path to fail by patching the
  2361. # SpoolmanKProfile model class with a sentinel that raises when
  2362. # instantiated. The MQTT call has already happened by then, so the
  2363. # endpoint must catch and log without returning 500.
  2364. with (
  2365. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2366. patch(
  2367. "backend.app.models.spoolman_k_profile.SpoolmanKProfile",
  2368. side_effect=RuntimeError("Simulated DB error"),
  2369. ),
  2370. ):
  2371. mock_pm.get_client.return_value = mock_client
  2372. mock_pm.get_status.return_value = mock_state
  2373. response = await async_client.post(
  2374. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2375. params={
  2376. "tray_info_idx": "GFL05",
  2377. "tray_type": "PLA",
  2378. "tray_sub_brands": "PLA Basic",
  2379. "tray_color": "FFFFFFFF",
  2380. "nozzle_temp_min": 190,
  2381. "nozzle_temp_max": 230,
  2382. "cali_idx": 5,
  2383. "k_value": 0.020,
  2384. },
  2385. )
  2386. # Endpoint returns success — MQTT was sent, K-profile failed silently
  2387. assert response.status_code == 200
  2388. # MQTT was indeed called
  2389. mock_client.extrusion_cali_sel.assert_called_once()