test_printers_api.py 160 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823
  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. @pytest.fixture(autouse=True)
  10. def _mock_printer_test_connection():
  11. """Default mock: connection test returns success.
  12. POST /printers/ now refuses to persist a printer when the MQTT
  13. connection probe fails (would otherwise leave an empty card in the
  14. dashboard for a mistyped access code). Existing tests assume the
  15. save succeeds, so we mock the probe green by default; the failure
  16. branch is exercised by a dedicated test below.
  17. """
  18. with patch(
  19. "backend.app.services.printer_manager.printer_manager.test_connection",
  20. new=AsyncMock(return_value={"success": True, "state": "IDLE", "model": "X1C"}),
  21. ) as m:
  22. yield m
  23. class TestPrintersAPI:
  24. """Integration tests for /api/v1/printers/ endpoints."""
  25. # ========================================================================
  26. # List endpoints
  27. # ========================================================================
  28. @pytest.mark.asyncio
  29. @pytest.mark.integration
  30. async def test_list_printers_empty(self, async_client: AsyncClient):
  31. """Verify empty list is returned when no printers exist."""
  32. response = await async_client.get("/api/v1/printers/")
  33. assert response.status_code == 200
  34. assert response.json() == []
  35. @pytest.mark.asyncio
  36. @pytest.mark.integration
  37. async def test_list_printers_with_data(self, async_client: AsyncClient, printer_factory, db_session):
  38. """Verify list returns existing printers."""
  39. await printer_factory(name="Test Printer")
  40. response = await async_client.get("/api/v1/printers/")
  41. assert response.status_code == 200
  42. data = response.json()
  43. assert len(data) >= 1
  44. assert any(p["name"] == "Test Printer" for p in data)
  45. # ========================================================================
  46. # Create endpoints
  47. # ========================================================================
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_create_printer(self, async_client: AsyncClient):
  51. """Verify printer can be created."""
  52. data = {
  53. "name": "New Printer",
  54. "serial_number": "00M09A111111111",
  55. "ip_address": "192.168.1.100",
  56. "access_code": "12345678",
  57. "is_active": True,
  58. "model": "X1C",
  59. }
  60. response = await async_client.post("/api/v1/printers/", json=data)
  61. assert response.status_code == 200
  62. result = response.json()
  63. assert result["name"] == "New Printer"
  64. assert result["serial_number"] == "00M09A111111111"
  65. assert result["model"] == "X1C"
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_create_printer_with_hostname(self, async_client: AsyncClient):
  69. """Verify printer can be created with a hostname instead of IP address."""
  70. data = {
  71. "name": "DNS Printer",
  72. "serial_number": "00M09A555555555",
  73. "ip_address": "printer.local",
  74. "access_code": "12345678",
  75. "model": "P1S",
  76. }
  77. response = await async_client.post("/api/v1/printers/", json=data)
  78. assert response.status_code == 200
  79. result = response.json()
  80. assert result["name"] == "DNS Printer"
  81. assert result["ip_address"] == "printer.local"
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_create_printer_with_fqdn(self, async_client: AsyncClient):
  85. """Verify printer can be created with a fully qualified domain name."""
  86. data = {
  87. "name": "FQDN Printer",
  88. "serial_number": "00M09A666666666",
  89. "ip_address": "my-printer.home.lan",
  90. "access_code": "12345678",
  91. "model": "X1C",
  92. }
  93. response = await async_client.post("/api/v1/printers/", json=data)
  94. assert response.status_code == 200
  95. result = response.json()
  96. assert result["ip_address"] == "my-printer.home.lan"
  97. @pytest.mark.asyncio
  98. @pytest.mark.integration
  99. async def test_create_printer_invalid_hostname(self, async_client: AsyncClient):
  100. """Verify invalid hostnames are rejected."""
  101. data = {
  102. "name": "Bad Printer",
  103. "serial_number": "00M09A777777777",
  104. "ip_address": "-invalid",
  105. "access_code": "12345678",
  106. }
  107. response = await async_client.post("/api/v1/printers/", json=data)
  108. assert response.status_code == 422
  109. @pytest.mark.asyncio
  110. @pytest.mark.integration
  111. async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):
  112. """Verify duplicate serial number is rejected."""
  113. await printer_factory(serial_number="00M09A222222222")
  114. data = {
  115. "name": "Duplicate Printer",
  116. "serial_number": "00M09A222222222",
  117. "ip_address": "192.168.1.101",
  118. "access_code": "12345678",
  119. }
  120. response = await async_client.post("/api/v1/printers/", json=data)
  121. # Should fail due to duplicate serial
  122. assert response.status_code in [400, 409, 422, 500]
  123. @pytest.mark.asyncio
  124. @pytest.mark.integration
  125. async def test_create_printer_rejects_when_mqtt_probe_fails(self, async_client: AsyncClient, db_session):
  126. """Wrong access code / unreachable IP must NOT persist the printer.
  127. Regression: users were reporting empty / never-connecting printer
  128. cards that traced back to a mistyped access code. The create route
  129. now runs an MQTT probe up front and returns 400 if it fails — the
  130. row is never written.
  131. """
  132. data = {
  133. "name": "Bad Code Printer",
  134. "serial_number": "00M09A999999999",
  135. "ip_address": "192.168.1.250",
  136. "access_code": "WRONG-CODE",
  137. "is_active": True,
  138. "model": "X1C",
  139. }
  140. with patch(
  141. "backend.app.services.printer_manager.printer_manager.test_connection",
  142. new=AsyncMock(return_value={"success": False, "state": None, "model": None}),
  143. ):
  144. response = await async_client.post("/api/v1/printers/", json=data)
  145. assert response.status_code == 400
  146. detail = response.json()["detail"]
  147. # Backend returns a stable code for the frontend i18n layer to map;
  148. # the message field is an English fallback for non-UI clients.
  149. assert detail["code"] == "printer_connection_failed"
  150. assert "connect" in detail["message"].lower()
  151. # And critically: the printer row was never persisted.
  152. from backend.app.models.printer import Printer
  153. result = await db_session.execute(select(Printer).where(Printer.serial_number == "00M09A999999999"))
  154. assert result.scalar_one_or_none() is None, "Failed-probe printer must not be persisted"
  155. # ========================================================================
  156. # Get single endpoint
  157. # ========================================================================
  158. @pytest.mark.asyncio
  159. @pytest.mark.integration
  160. async def test_get_printer(self, async_client: AsyncClient, printer_factory, db_session):
  161. """Verify single printer can be retrieved."""
  162. printer = await printer_factory(name="Get Test Printer")
  163. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  164. assert response.status_code == 200
  165. result = response.json()
  166. assert result["id"] == printer.id
  167. assert result["name"] == "Get Test Printer"
  168. @pytest.mark.asyncio
  169. @pytest.mark.integration
  170. async def test_get_printer_not_found(self, async_client: AsyncClient):
  171. """Verify 404 for non-existent printer."""
  172. response = await async_client.get("/api/v1/printers/9999")
  173. assert response.status_code == 404
  174. # ========================================================================
  175. # Update endpoints
  176. # ========================================================================
  177. @pytest.mark.asyncio
  178. @pytest.mark.integration
  179. async def test_update_printer_name(self, async_client: AsyncClient, printer_factory, db_session):
  180. """Verify printer name can be updated."""
  181. printer = await printer_factory(name="Original Name")
  182. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated Name"})
  183. assert response.status_code == 200
  184. assert response.json()["name"] == "Updated Name"
  185. @pytest.mark.asyncio
  186. @pytest.mark.integration
  187. async def test_update_printer_active_status(self, async_client: AsyncClient, printer_factory, db_session):
  188. """Verify printer active status can be updated."""
  189. printer = await printer_factory(is_active=True)
  190. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"is_active": False})
  191. assert response.status_code == 200
  192. assert response.json()["is_active"] is False
  193. @pytest.mark.asyncio
  194. @pytest.mark.integration
  195. async def test_update_printer_auto_archive(self, async_client: AsyncClient, printer_factory, db_session):
  196. """Verify auto_archive setting can be updated."""
  197. printer = await printer_factory(auto_archive=True)
  198. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"auto_archive": False})
  199. assert response.status_code == 200
  200. assert response.json()["auto_archive"] is False
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. async def test_update_nonexistent_printer(self, async_client: AsyncClient):
  204. """Verify updating non-existent printer returns 404."""
  205. response = await async_client.patch("/api/v1/printers/9999", json={"name": "New Name"})
  206. assert response.status_code == 404
  207. # ========================================================================
  208. # Delete endpoints
  209. # ========================================================================
  210. @pytest.mark.asyncio
  211. @pytest.mark.integration
  212. async def test_delete_printer(self, async_client: AsyncClient, printer_factory, db_session):
  213. """Verify printer can be deleted."""
  214. printer = await printer_factory()
  215. printer_id = printer.id
  216. response = await async_client.delete(f"/api/v1/printers/{printer_id}")
  217. assert response.status_code == 200
  218. # Verify deleted
  219. response = await async_client.get(f"/api/v1/printers/{printer_id}")
  220. assert response.status_code == 404
  221. @pytest.mark.asyncio
  222. @pytest.mark.integration
  223. async def test_delete_nonexistent_printer(self, async_client: AsyncClient):
  224. """Verify deleting non-existent printer returns 404."""
  225. response = await async_client.delete("/api/v1/printers/9999")
  226. assert response.status_code == 404
  227. # ========================================================================
  228. # File download endpoint — non-ASCII filename regression (#1245)
  229. # ========================================================================
  230. @pytest.mark.asyncio
  231. @pytest.mark.integration
  232. @pytest.mark.parametrize(
  233. ("filename", "ascii_fallback"),
  234. [
  235. ("龙泡泡石墩子_p2s_ok.gcode.3mf", "p2s_ok.gcode.3mf"),
  236. ("こんにちは.gcode.3mf", "gcode.3mf"),
  237. ("résumé.gcode.3mf", "rsum.gcode.3mf"),
  238. ("مرحبا.gcode.3mf", "gcode.3mf"),
  239. ("文件.3mf", "3mf"),
  240. ("hello.3mf", "hello.3mf"),
  241. ],
  242. )
  243. async def test_download_printer_file_non_ascii_filename(
  244. self,
  245. async_client: AsyncClient,
  246. printer_factory,
  247. filename: str,
  248. ascii_fallback: str,
  249. db_session,
  250. ):
  251. """Non-ASCII filenames must not crash header encoding (issue #1245)."""
  252. printer = await printer_factory()
  253. file_bytes = b"fake 3mf content"
  254. with patch(
  255. "backend.app.api.routes.printers.download_file_bytes_async",
  256. new=AsyncMock(return_value=file_bytes),
  257. ):
  258. response = await async_client.get(
  259. f"/api/v1/printers/{printer.id}/files/download",
  260. params={"path": f"/cache/{filename}"},
  261. )
  262. assert response.status_code == 200
  263. assert response.content == file_bytes
  264. content_disposition = response.headers["content-disposition"]
  265. assert f'filename="{ascii_fallback}"' in content_disposition
  266. assert "filename*=UTF-8''" in content_disposition
  267. encoded_name = content_disposition.split("filename*=UTF-8''", 1)[1]
  268. assert unquote(encoded_name) == filename
  269. # ========================================================================
  270. # Status endpoint
  271. # ========================================================================
  272. @pytest.mark.asyncio
  273. @pytest.mark.integration
  274. async def test_get_printer_status(
  275. self, async_client: AsyncClient, printer_factory, mock_printer_manager, db_session
  276. ):
  277. """Verify printer status can be retrieved."""
  278. printer = await printer_factory()
  279. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  280. assert response.status_code == 200
  281. result = response.json()
  282. assert "connected" in result
  283. assert "state" in result
  284. @pytest.mark.asyncio
  285. @pytest.mark.integration
  286. async def test_get_printer_status_not_found(self, async_client: AsyncClient):
  287. """Verify 404 for status of non-existent printer."""
  288. response = await async_client.get("/api/v1/printers/9999/status")
  289. assert response.status_code == 404
  290. @pytest.mark.asyncio
  291. @pytest.mark.integration
  292. async def test_get_printer_status_includes_fila_switch_when_installed(
  293. self, async_client: AsyncClient, printer_factory, db_session
  294. ):
  295. """When the FTS accessory is installed, the status response must include
  296. the fila_switch object with the routing arrays. See #1162.
  297. The accessory is detected from print.device.fila_switch in MQTT;
  298. we feed a PrinterState with FilaSwitchState(installed=True, ...) and
  299. confirm it survives the schema serialization round-trip.
  300. """
  301. from unittest.mock import MagicMock, patch
  302. from backend.app.services.bambu_mqtt import FilaSwitchState, PrinterState
  303. printer = await printer_factory()
  304. state = PrinterState()
  305. state.connected = True
  306. state.state = "IDLE"
  307. state.fila_switch = FilaSwitchState(
  308. installed=True,
  309. in_slots=[-1, 2],
  310. out_extruders=[0, 1],
  311. stat=0,
  312. info=2,
  313. )
  314. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  315. mock_pm.get_status = MagicMock(return_value=state)
  316. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  317. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  318. assert response.status_code == 200
  319. result = response.json()
  320. assert result["fila_switch"] is not None
  321. assert result["fila_switch"]["installed"] is True
  322. assert result["fila_switch"]["in_slots"] == [-1, 2]
  323. assert result["fila_switch"]["out_extruders"] == [0, 1]
  324. assert result["fila_switch"]["stat"] == 0
  325. assert result["fila_switch"]["info"] == 2
  326. @pytest.mark.asyncio
  327. @pytest.mark.integration
  328. async def test_cover_uses_dispatched_plate_when_gcode_file_lacks_path(
  329. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  330. ):
  331. """When firmware drops the plate path from gcode_file (e.g. P1S
  332. 01.10.00.00, #1166), the dispatched-plate record must take precedence
  333. and serve plate 4's thumbnail instead of falling back to plate_1.png."""
  334. import io
  335. import zipfile
  336. from unittest.mock import MagicMock, patch
  337. from backend.app.services.bambu_ftp import cache_3mf_download
  338. from backend.app.services.bambu_mqtt import PrinterState
  339. printer = await printer_factory()
  340. # Build a 3MF that mimics a "true" multi-plate archive: thumbnails
  341. # for plates 1..4 are all present, gcode files for plates 1..4 are
  342. # all present. Without the dispatch record we'd default to plate_1.png.
  343. threemf_path = tmp_path / "MyModel.3mf"
  344. with zipfile.ZipFile(threemf_path, "w") as zf:
  345. for plate in range(1, 5):
  346. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  347. zf.writestr(f"Metadata/plate_{plate}.gcode", f"; plate {plate} gcode\n")
  348. cache_3mf_download(printer.id, "MyModel.3mf", threemf_path)
  349. state = PrinterState()
  350. state.connected = True
  351. state.state = "RUNNING"
  352. state.subtask_name = "MyModel"
  353. state.gcode_file = "MyModel.3mf" # firmware drops plate path
  354. state.dispatched_plate_id = 4
  355. state.dispatched_subtask = "MyModel"
  356. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  357. mock_pm.get_status = MagicMock(return_value=state)
  358. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  359. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  360. assert response.status_code == 200
  361. assert response.content == b"PLATE_4_PNG"
  362. @pytest.mark.asyncio
  363. @pytest.mark.integration
  364. async def test_cover_3mf_scan_fallback_for_per_plate_archive(
  365. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  366. ):
  367. """Per-plate archives sliced separately in Bambu Studio contain a
  368. single Metadata/plate_N.gcode (the active plate) but bundle thumbnails
  369. for every plate. With no dispatch record (e.g. dispatched via Studio
  370. directly) and no plate path in gcode_file, the route must scan the
  371. 3MF and pick plate N's thumbnail. See #1166 option 4."""
  372. import zipfile
  373. from unittest.mock import MagicMock, patch
  374. from backend.app.services.bambu_ftp import cache_3mf_download
  375. from backend.app.services.bambu_mqtt import PrinterState
  376. printer = await printer_factory()
  377. # Per-plate archive: thumbnails for all plates, gcode for plate 3 only.
  378. threemf_path = tmp_path / "PerPlate.3mf"
  379. with zipfile.ZipFile(threemf_path, "w") as zf:
  380. for plate in range(1, 5):
  381. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  382. zf.writestr("Metadata/plate_3.gcode", "; only plate 3 has gcode\n")
  383. cache_3mf_download(printer.id, "PerPlate.3mf", threemf_path)
  384. state = PrinterState()
  385. state.connected = True
  386. state.state = "RUNNING"
  387. state.subtask_name = "PerPlate"
  388. state.gcode_file = "PerPlate.3mf"
  389. # No dispatch record (Studio-direct dispatch).
  390. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  391. mock_pm.get_status = MagicMock(return_value=state)
  392. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  393. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  394. assert response.status_code == 200
  395. assert response.content == b"PLATE_3_PNG"
  396. @pytest.mark.asyncio
  397. @pytest.mark.integration
  398. async def test_cover_negative_cache_skips_repeat_ftp_fanout(
  399. self, async_client: AsyncClient, printer_factory, db_session
  400. ):
  401. """#1420: when every FTP path returns 550 for the current subtask, the
  402. next request for the same subtask must short-circuit to 404 instead of
  403. replaying the 8-path FTP fan-out (which starves the printer's single
  404. FTP socket and flooded the user's logs)."""
  405. from unittest.mock import AsyncMock, MagicMock, patch
  406. from backend.app.api.routes.printers import _cover_404_cache, _cover_cache
  407. from backend.app.services.bambu_mqtt import PrinterState
  408. printer = await printer_factory()
  409. _cover_cache.pop(printer.id, None)
  410. _cover_404_cache.pop(printer.id, None)
  411. state = PrinterState()
  412. state.connected = True
  413. state.state = "RUNNING"
  414. state.subtask_name = "OrphanPrint"
  415. state.gcode_file = "OrphanPrint.3mf"
  416. ftp_mock = AsyncMock(return_value=False)
  417. with (
  418. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  419. patch("backend.app.api.routes.printers.download_file_try_paths_async", ftp_mock),
  420. ):
  421. mock_pm.get_status = MagicMock(return_value=state)
  422. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  423. r1 = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  424. r2 = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  425. assert r1.status_code == 404
  426. assert r2.status_code == 404
  427. # First call retries internally; second call must short-circuit before FTP.
  428. first_call_count = ftp_mock.await_count
  429. assert first_call_count >= 1
  430. # Second request didn't add to the count: the negative cache held.
  431. assert ftp_mock.await_count == first_call_count
  432. @pytest.mark.asyncio
  433. @pytest.mark.integration
  434. async def test_get_printer_status_omits_fila_switch_when_not_installed(
  435. self, async_client: AsyncClient, printer_factory, db_session
  436. ):
  437. """Without the FTS accessory, fila_switch must be null so the frontend
  438. keeps applying the per-extruder filter on regular dual-nozzle printers."""
  439. from unittest.mock import MagicMock, patch
  440. from backend.app.services.bambu_mqtt import PrinterState
  441. printer = await printer_factory()
  442. state = PrinterState()
  443. state.connected = True
  444. state.state = "IDLE"
  445. # default fila_switch — installed = False
  446. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  447. mock_pm.get_status = MagicMock(return_value=state)
  448. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  449. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  450. assert response.status_code == 200
  451. result = response.json()
  452. assert result["fila_switch"] is None
  453. # ========================================================================
  454. # Test connection endpoint
  455. # ========================================================================
  456. class TestPrinterDataIntegrity:
  457. """Tests for printer data integrity."""
  458. @pytest.mark.asyncio
  459. @pytest.mark.integration
  460. async def test_printer_stores_all_fields(self, async_client: AsyncClient, printer_factory, db_session):
  461. """Verify printer stores all fields correctly."""
  462. printer = await printer_factory(
  463. name="Full Test Printer",
  464. serial_number="00M09A444444444",
  465. ip_address="192.168.1.150",
  466. model="P1S",
  467. is_active=True,
  468. auto_archive=False,
  469. )
  470. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  471. assert response.status_code == 200
  472. result = response.json()
  473. assert result["name"] == "Full Test Printer"
  474. assert result["serial_number"] == "00M09A444444444"
  475. assert result["ip_address"] == "192.168.1.150"
  476. assert result["model"] == "P1S"
  477. assert result["is_active"] is True
  478. assert result["auto_archive"] is False
  479. @pytest.mark.asyncio
  480. @pytest.mark.integration
  481. async def test_printer_update_persists(self, async_client: AsyncClient, printer_factory, db_session):
  482. """CRITICAL: Verify printer updates persist."""
  483. printer = await printer_factory(name="Original", is_active=True)
  484. # Update
  485. await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated", "is_active": False})
  486. # Verify persistence
  487. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  488. result = response.json()
  489. assert result["name"] == "Updated"
  490. assert result["is_active"] is False
  491. # ========================================================================
  492. # Refresh status endpoint
  493. # ========================================================================
  494. @pytest.mark.asyncio
  495. @pytest.mark.integration
  496. async def test_refresh_status_not_found(self, async_client: AsyncClient):
  497. """Verify 404 for non-existent printer."""
  498. response = await async_client.post("/api/v1/printers/99999/refresh-status")
  499. assert response.status_code == 404
  500. @pytest.mark.asyncio
  501. @pytest.mark.integration
  502. async def test_refresh_status_not_connected(self, async_client: AsyncClient, printer_factory):
  503. """Verify 400 when printer is not connected."""
  504. printer = await printer_factory(name="Disconnected Printer")
  505. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  506. mock_pm.request_status_update.return_value = False
  507. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  508. assert response.status_code == 400
  509. assert "not connected" in response.json()["detail"].lower()
  510. @pytest.mark.asyncio
  511. @pytest.mark.integration
  512. async def test_refresh_status_success(self, async_client: AsyncClient, printer_factory):
  513. """Verify successful refresh request."""
  514. printer = await printer_factory(name="Connected Printer")
  515. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  516. mock_pm.request_status_update.return_value = True
  517. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  518. assert response.status_code == 200
  519. assert response.json()["status"] == "refresh_requested"
  520. mock_pm.request_status_update.assert_called_once_with(printer.id)
  521. # ========================================================================
  522. # Current print user endpoint (Issue #206)
  523. # ========================================================================
  524. @pytest.mark.asyncio
  525. @pytest.mark.integration
  526. async def test_get_current_print_user_not_found(self, async_client: AsyncClient):
  527. """Verify 404 for non-existent printer."""
  528. response = await async_client.get("/api/v1/printers/99999/current-print-user")
  529. assert response.status_code == 404
  530. @pytest.mark.asyncio
  531. @pytest.mark.integration
  532. async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):
  533. """Verify empty object returned when no user is tracked."""
  534. printer = await printer_factory(name="Test Printer")
  535. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  536. mock_pm.get_current_print_user.return_value = None
  537. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  538. assert response.status_code == 200
  539. assert response.json() == {}
  540. @pytest.mark.asyncio
  541. @pytest.mark.integration
  542. async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):
  543. """Verify user info is returned when tracked."""
  544. printer = await printer_factory(name="Test Printer")
  545. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  546. mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser"}
  547. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  548. assert response.status_code == 200
  549. result = response.json()
  550. assert result["user_id"] == 42
  551. assert result["username"] == "testuser"
  552. class TestPrintControlAPI:
  553. """Integration tests for print control endpoints (stop, pause, resume)."""
  554. # ========================================================================
  555. # Stop print endpoint
  556. # ========================================================================
  557. @pytest.mark.asyncio
  558. @pytest.mark.integration
  559. async def test_stop_print_not_found(self, async_client: AsyncClient):
  560. """Verify 404 for non-existent printer."""
  561. response = await async_client.post("/api/v1/printers/99999/print/stop")
  562. assert response.status_code == 404
  563. @pytest.mark.asyncio
  564. @pytest.mark.integration
  565. async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):
  566. """Verify error when printer is not connected."""
  567. printer = await printer_factory(name="Disconnected Printer")
  568. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  569. mock_pm.get_client.return_value = None
  570. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  571. assert response.status_code == 400
  572. assert "not connected" in response.json()["detail"].lower()
  573. @pytest.mark.asyncio
  574. @pytest.mark.integration
  575. async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):
  576. """Verify successful stop print request."""
  577. printer = await printer_factory(name="Printing Printer")
  578. mock_client = MagicMock()
  579. mock_client.stop_print.return_value = True
  580. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  581. mock_pm.get_client.return_value = mock_client
  582. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  583. assert response.status_code == 200
  584. assert response.json()["success"] is True
  585. mock_client.stop_print.assert_called_once()
  586. # ========================================================================
  587. # Pause print endpoint
  588. # ========================================================================
  589. @pytest.mark.asyncio
  590. @pytest.mark.integration
  591. async def test_pause_print_not_found(self, async_client: AsyncClient):
  592. """Verify 404 for non-existent printer."""
  593. response = await async_client.post("/api/v1/printers/99999/print/pause")
  594. assert response.status_code == 404
  595. @pytest.mark.asyncio
  596. @pytest.mark.integration
  597. async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):
  598. """Verify error when printer is not connected."""
  599. printer = await printer_factory(name="Disconnected Printer")
  600. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  601. mock_pm.get_client.return_value = None
  602. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  603. assert response.status_code == 400
  604. assert "not connected" in response.json()["detail"].lower()
  605. @pytest.mark.asyncio
  606. @pytest.mark.integration
  607. async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):
  608. """Verify successful pause print request."""
  609. printer = await printer_factory(name="Printing Printer")
  610. mock_client = MagicMock()
  611. mock_client.pause_print.return_value = True
  612. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  613. mock_pm.get_client.return_value = mock_client
  614. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  615. assert response.status_code == 200
  616. assert response.json()["success"] is True
  617. mock_client.pause_print.assert_called_once()
  618. # ========================================================================
  619. # Resume print endpoint
  620. # ========================================================================
  621. @pytest.mark.asyncio
  622. @pytest.mark.integration
  623. async def test_resume_print_not_found(self, async_client: AsyncClient):
  624. """Verify 404 for non-existent printer."""
  625. response = await async_client.post("/api/v1/printers/99999/print/resume")
  626. assert response.status_code == 404
  627. @pytest.mark.asyncio
  628. @pytest.mark.integration
  629. async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):
  630. """Verify error when printer is not connected."""
  631. printer = await printer_factory(name="Disconnected Printer")
  632. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  633. mock_pm.get_client.return_value = None
  634. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  635. assert response.status_code == 400
  636. assert "not connected" in response.json()["detail"].lower()
  637. @pytest.mark.asyncio
  638. @pytest.mark.integration
  639. async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):
  640. """Verify successful resume print request."""
  641. printer = await printer_factory(name="Paused Printer")
  642. mock_client = MagicMock()
  643. mock_client.resume_print.return_value = True
  644. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  645. mock_pm.get_client.return_value = mock_client
  646. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  647. assert response.status_code == 200
  648. assert response.json()["success"] is True
  649. mock_client.resume_print.assert_called_once()
  650. class TestAMSRefreshAPI:
  651. """Integration tests for AMS slot refresh endpoint."""
  652. @pytest.mark.asyncio
  653. @pytest.mark.integration
  654. async def test_ams_refresh_not_found(self, async_client: AsyncClient):
  655. """Verify 404 for non-existent printer."""
  656. response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh")
  657. assert response.status_code == 404
  658. @pytest.mark.asyncio
  659. @pytest.mark.integration
  660. async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):
  661. """Verify error when printer is not connected."""
  662. printer = await printer_factory(name="Disconnected Printer")
  663. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  664. mock_pm.get_client.return_value = None
  665. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  666. assert response.status_code == 400
  667. assert "not connected" in response.json()["detail"].lower()
  668. @pytest.mark.asyncio
  669. @pytest.mark.integration
  670. async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):
  671. """Verify successful AMS refresh request."""
  672. printer = await printer_factory(name="Printer with AMS")
  673. mock_client = MagicMock()
  674. mock_client.ams_refresh_tray.return_value = (True, "Refreshing AMS 0 tray 1")
  675. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  676. mock_pm.get_client.return_value = mock_client
  677. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh")
  678. assert response.status_code == 200
  679. result = response.json()
  680. assert result["success"] is True
  681. mock_client.ams_refresh_tray.assert_called_once_with(0, 1)
  682. @pytest.mark.asyncio
  683. @pytest.mark.integration
  684. async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):
  685. """Verify error when filament is loaded (can't refresh while loaded)."""
  686. printer = await printer_factory(name="Printer with AMS")
  687. mock_client = MagicMock()
  688. mock_client.ams_refresh_tray.return_value = (False, "Please unload filament first")
  689. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  690. mock_pm.get_client.return_value = mock_client
  691. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  692. assert response.status_code == 400
  693. assert "unload" in response.json()["detail"].lower()
  694. class TestAMSLoadUnloadAPI:
  695. """Integration tests for AMS load / unload endpoints (#891)."""
  696. # ── load ─────────────────────────────────────────────────────────────────
  697. @pytest.mark.asyncio
  698. @pytest.mark.integration
  699. async def test_load_invalid_tray_id(self, async_client: AsyncClient, printer_factory):
  700. """tray_id outside {0..15, 254, 255} is rejected."""
  701. printer = await printer_factory(name="P")
  702. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=99")
  703. assert response.status_code == 400
  704. @pytest.mark.asyncio
  705. @pytest.mark.integration
  706. async def test_load_not_found(self, async_client: AsyncClient):
  707. response = await async_client.post("/api/v1/printers/99999/ams/load?tray_id=0")
  708. assert response.status_code == 404
  709. @pytest.mark.asyncio
  710. @pytest.mark.integration
  711. async def test_load_not_connected(self, async_client: AsyncClient, printer_factory):
  712. printer = await printer_factory(name="Disconnected")
  713. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  714. mock_pm.get_client.return_value = None
  715. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  716. assert response.status_code == 400
  717. assert "not connected" in response.json()["detail"].lower()
  718. @pytest.mark.asyncio
  719. @pytest.mark.integration
  720. async def test_load_ams_slot_success(self, async_client: AsyncClient, printer_factory):
  721. """tray_id=5 → AMS 1 slot 2 (1-indexed in the message)."""
  722. printer = await printer_factory(name="P")
  723. mock_client = MagicMock()
  724. mock_client.ams_load_filament.return_value = True
  725. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  726. mock_pm.get_client.return_value = mock_client
  727. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5")
  728. assert response.status_code == 200
  729. mock_client.ams_load_filament.assert_called_once_with(5)
  730. assert "AMS 1" in response.json()["message"]
  731. @pytest.mark.asyncio
  732. @pytest.mark.integration
  733. async def test_load_external_left_success(self, async_client: AsyncClient, printer_factory):
  734. """tray_id=254 → external spool / Ext-L."""
  735. printer = await printer_factory(name="P")
  736. mock_client = MagicMock()
  737. mock_client.ams_load_filament.return_value = True
  738. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  739. mock_pm.get_client.return_value = mock_client
  740. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=254")
  741. assert response.status_code == 200
  742. mock_client.ams_load_filament.assert_called_once_with(254)
  743. assert "external" in response.json()["message"].lower()
  744. @pytest.mark.asyncio
  745. @pytest.mark.integration
  746. async def test_load_external_right_success(self, async_client: AsyncClient, printer_factory):
  747. """tray_id=255 → Ext-R on dual-nozzle H2D."""
  748. printer = await printer_factory(name="H2D")
  749. mock_client = MagicMock()
  750. mock_client.ams_load_filament.return_value = True
  751. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  752. mock_pm.get_client.return_value = mock_client
  753. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=255")
  754. assert response.status_code == 200
  755. mock_client.ams_load_filament.assert_called_once_with(255)
  756. assert "Ext-R" in response.json()["message"]
  757. @pytest.mark.asyncio
  758. @pytest.mark.integration
  759. async def test_load_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  760. printer = await printer_factory(name="P")
  761. mock_client = MagicMock()
  762. mock_client.ams_load_filament.return_value = False
  763. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  764. mock_pm.get_client.return_value = mock_client
  765. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  766. assert response.status_code == 500
  767. assert "failed" in response.json()["detail"].lower()
  768. # ── unload ───────────────────────────────────────────────────────────────
  769. @pytest.mark.asyncio
  770. @pytest.mark.integration
  771. async def test_unload_not_found(self, async_client: AsyncClient):
  772. response = await async_client.post("/api/v1/printers/99999/ams/unload")
  773. assert response.status_code == 404
  774. @pytest.mark.asyncio
  775. @pytest.mark.integration
  776. async def test_unload_not_connected(self, async_client: AsyncClient, printer_factory):
  777. printer = await printer_factory(name="Disconnected")
  778. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  779. mock_pm.get_client.return_value = None
  780. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  781. assert response.status_code == 400
  782. assert "not connected" in response.json()["detail"].lower()
  783. @pytest.mark.asyncio
  784. @pytest.mark.integration
  785. async def test_unload_success(self, async_client: AsyncClient, printer_factory):
  786. printer = await printer_factory(name="P")
  787. mock_client = MagicMock()
  788. mock_client.ams_unload_filament.return_value = True
  789. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  790. mock_pm.get_client.return_value = mock_client
  791. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  792. assert response.status_code == 200
  793. mock_client.ams_unload_filament.assert_called_once_with()
  794. assert response.json()["success"] is True
  795. @pytest.mark.asyncio
  796. @pytest.mark.integration
  797. async def test_unload_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  798. printer = await printer_factory(name="P")
  799. mock_client = MagicMock()
  800. mock_client.ams_unload_filament.return_value = False
  801. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  802. mock_pm.get_client.return_value = mock_client
  803. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  804. assert response.status_code == 500
  805. assert "failed" in response.json()["detail"].lower()
  806. class TestConfigureAMSSlotAPI:
  807. """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
  808. @pytest.mark.asyncio
  809. @pytest.mark.integration
  810. async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
  811. """Verify error when printer is not connected."""
  812. printer = await printer_factory(name="Disconnected")
  813. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  814. mock_pm.get_client.return_value = None
  815. response = await async_client.post(
  816. f"/api/v1/printers/{printer.id}/slots/0/0/configure",
  817. params={
  818. "tray_info_idx": "GFL99",
  819. "tray_type": "PLA",
  820. "tray_sub_brands": "PLA Basic",
  821. "tray_color": "FF0000FF",
  822. "nozzle_temp_min": 190,
  823. "nozzle_temp_max": 230,
  824. },
  825. )
  826. assert response.status_code == 400
  827. assert "not connected" in response.json()["detail"].lower()
  828. @pytest.mark.asyncio
  829. @pytest.mark.integration
  830. async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
  831. """Standard Bambu GF* filament IDs are sent as-is."""
  832. printer = await printer_factory(name="H2D")
  833. mock_client = MagicMock()
  834. mock_client.ams_set_filament_setting.return_value = True
  835. mock_client.extrusion_cali_sel.return_value = True
  836. mock_client.request_status_update.return_value = True
  837. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  838. mock_pm.get_client.return_value = mock_client
  839. mock_pm.get_status.return_value = None # No existing state
  840. response = await async_client.post(
  841. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  842. params={
  843. "tray_info_idx": "GFL05",
  844. "tray_type": "PLA",
  845. "tray_sub_brands": "PLA Basic",
  846. "tray_color": "FFFFFFFF",
  847. "nozzle_temp_min": 190,
  848. "nozzle_temp_max": 230,
  849. },
  850. )
  851. assert response.status_code == 200
  852. call_kwargs = mock_client.ams_set_filament_setting.call_args
  853. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  854. @pytest.mark.asyncio
  855. @pytest.mark.integration
  856. async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
  857. """PFUS* cloud-synced custom preset IDs are sent to the printer."""
  858. printer = await printer_factory(name="H2D")
  859. mock_client = MagicMock()
  860. mock_client.ams_set_filament_setting.return_value = True
  861. mock_client.extrusion_cali_sel.return_value = True
  862. mock_client.request_status_update.return_value = True
  863. mock_status = MagicMock()
  864. mock_status.raw_data = {"ams": {"ams": []}} # No existing tray data
  865. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  866. mock_pm.get_client.return_value = mock_client
  867. mock_pm.get_status.return_value = mock_status
  868. response = await async_client.post(
  869. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  870. params={
  871. "tray_info_idx": "PFUS9ac902733670a9",
  872. "tray_type": "PLA",
  873. "tray_sub_brands": "Devil Design PLA",
  874. "tray_color": "FF0000FF",
  875. "nozzle_temp_min": 190,
  876. "nozzle_temp_max": 230,
  877. },
  878. )
  879. assert response.status_code == 200
  880. call_kwargs = mock_client.ams_set_filament_setting.call_args
  881. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  882. @pytest.mark.asyncio
  883. @pytest.mark.integration
  884. async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
  885. """Provided PFUS* preset takes priority over slot's existing preset."""
  886. printer = await printer_factory(name="H2D")
  887. mock_client = MagicMock()
  888. mock_client.ams_set_filament_setting.return_value = True
  889. mock_client.extrusion_cali_sel.return_value = True
  890. mock_client.request_status_update.return_value = True
  891. # Simulate slot already configured by slicer with cloud-synced preset
  892. mock_status = MagicMock()
  893. mock_status.raw_data = {
  894. "ams": {
  895. "ams": [
  896. {
  897. "id": 2,
  898. "tray": [
  899. {
  900. "id": 3,
  901. "tray_info_idx": "P4d64437",
  902. "tray_type": "PLA",
  903. "tray_color": "FF0000FF",
  904. }
  905. ],
  906. }
  907. ]
  908. }
  909. }
  910. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  911. mock_pm.get_client.return_value = mock_client
  912. mock_pm.get_status.return_value = mock_status
  913. response = await async_client.post(
  914. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  915. params={
  916. "tray_info_idx": "PFUS9ac902733670a9",
  917. "tray_type": "PLA",
  918. "tray_sub_brands": "Devil Design PLA",
  919. "tray_color": "FF0000FF",
  920. "nozzle_temp_min": 190,
  921. "nozzle_temp_max": 230,
  922. },
  923. )
  924. assert response.status_code == 200
  925. call_kwargs = mock_client.ams_set_filament_setting.call_args
  926. # Provided preset wins over slot's existing one
  927. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  928. @pytest.mark.asyncio
  929. @pytest.mark.integration
  930. async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
  931. """Provided PFUS* preset is used even when slot has a different material."""
  932. printer = await printer_factory(name="H2D")
  933. mock_client = MagicMock()
  934. mock_client.ams_set_filament_setting.return_value = True
  935. mock_client.extrusion_cali_sel.return_value = True
  936. mock_client.request_status_update.return_value = True
  937. # Slot currently has PETG but user is configuring PLA
  938. mock_status = MagicMock()
  939. mock_status.raw_data = {
  940. "ams": {
  941. "ams": [
  942. {
  943. "id": 2,
  944. "tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
  945. }
  946. ]
  947. }
  948. }
  949. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  950. mock_pm.get_client.return_value = mock_client
  951. mock_pm.get_status.return_value = mock_status
  952. response = await async_client.post(
  953. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  954. params={
  955. "tray_info_idx": "PFUS9ac902733670a9",
  956. "tray_type": "PLA",
  957. "tray_sub_brands": "Devil Design PLA",
  958. "tray_color": "FF0000FF",
  959. "nozzle_temp_min": 190,
  960. "nozzle_temp_max": 230,
  961. },
  962. )
  963. assert response.status_code == 200
  964. call_kwargs = mock_client.ams_set_filament_setting.call_args
  965. # Provided preset wins — slot's material is irrelevant
  966. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  967. @pytest.mark.asyncio
  968. @pytest.mark.integration
  969. async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
  970. """Empty tray_info_idx (local preset) is replaced with generic."""
  971. printer = await printer_factory(name="H2D")
  972. mock_client = MagicMock()
  973. mock_client.ams_set_filament_setting.return_value = True
  974. mock_client.extrusion_cali_sel.return_value = True
  975. mock_client.request_status_update.return_value = True
  976. mock_status = MagicMock()
  977. mock_status.raw_data = {"ams": {"ams": []}}
  978. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  979. mock_pm.get_client.return_value = mock_client
  980. mock_pm.get_status.return_value = mock_status
  981. response = await async_client.post(
  982. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  983. params={
  984. "tray_info_idx": "",
  985. "tray_type": "PETG",
  986. "tray_sub_brands": "PETG Basic",
  987. "tray_color": "FFFFFFFF",
  988. "nozzle_temp_min": 220,
  989. "nozzle_temp_max": 260,
  990. },
  991. )
  992. assert response.status_code == 200
  993. call_kwargs = mock_client.ams_set_filament_setting.call_args
  994. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  995. @pytest.mark.asyncio
  996. @pytest.mark.integration
  997. async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
  998. """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
  999. Pins the end-to-end contract the frontend #1053 fix relies on: when the
  1000. user configures a slot with a custom cloud preset whose cloud detail
  1001. has filament_id=null, the frontend sends the setting_id in BOTH fields
  1002. and the backend must not collapse either to a generic GF* ID.
  1003. """
  1004. printer = await printer_factory(name="H2D")
  1005. mock_client = MagicMock()
  1006. mock_client.ams_set_filament_setting.return_value = True
  1007. mock_client.extrusion_cali_sel.return_value = True
  1008. mock_client.request_status_update.return_value = True
  1009. mock_status = MagicMock()
  1010. mock_status.raw_data = {"ams": {"ams": []}}
  1011. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1012. mock_pm.get_client.return_value = mock_client
  1013. mock_pm.get_status.return_value = mock_status
  1014. response = await async_client.post(
  1015. f"/api/v1/printers/{printer.id}/slots/128/0/configure",
  1016. params={
  1017. "tray_info_idx": "PFUSa8fb76f9733e3c",
  1018. "tray_type": "ABS",
  1019. "tray_sub_brands": "Sting3D ABS",
  1020. "tray_color": "000000FF",
  1021. "nozzle_temp_min": 240,
  1022. "nozzle_temp_max": 280,
  1023. "setting_id": "PFUSa8fb76f9733e3c",
  1024. },
  1025. )
  1026. assert response.status_code == 200
  1027. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1028. assert call_kwargs.kwargs["tray_info_idx"] == "PFUSa8fb76f9733e3c"
  1029. assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
  1030. # Explicitly assert no generic-collapse happened for this HT slot.
  1031. assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
  1032. class TestSkipObjectsAPI:
  1033. """Integration tests for skip objects endpoints."""
  1034. # ========================================================================
  1035. # Get printable objects endpoint
  1036. # ========================================================================
  1037. @pytest.mark.asyncio
  1038. @pytest.mark.integration
  1039. async def test_get_objects_not_found(self, async_client: AsyncClient):
  1040. """Verify 404 for non-existent printer."""
  1041. response = await async_client.get("/api/v1/printers/99999/print/objects")
  1042. assert response.status_code == 404
  1043. @pytest.mark.asyncio
  1044. @pytest.mark.integration
  1045. async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  1046. """Verify error when printer is not connected."""
  1047. printer = await printer_factory(name="Disconnected Printer")
  1048. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1049. mock_pm.get_client.return_value = None
  1050. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1051. assert response.status_code == 400
  1052. assert "not connected" in response.json()["detail"].lower()
  1053. @pytest.mark.asyncio
  1054. @pytest.mark.integration
  1055. async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):
  1056. """Verify empty objects list when no print is active."""
  1057. printer = await printer_factory(name="Idle Printer")
  1058. mock_client = MagicMock()
  1059. mock_client.state.printable_objects = {}
  1060. mock_client.state.skipped_objects = []
  1061. mock_client.state.state = "IDLE"
  1062. mock_client.state.subtask_name = None # Prevent FTP download attempt
  1063. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1064. mock_pm.get_client.return_value = mock_client
  1065. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1066. assert response.status_code == 200
  1067. result = response.json()
  1068. assert result["objects"] == []
  1069. assert result["total"] == 0
  1070. assert result["skipped_count"] == 0
  1071. assert result["is_printing"] is False
  1072. @pytest.mark.asyncio
  1073. @pytest.mark.integration
  1074. async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):
  1075. """Verify objects list when print is active."""
  1076. printer = await printer_factory(name="Printing Printer")
  1077. mock_client = MagicMock()
  1078. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  1079. mock_client.state.skipped_objects = [200]
  1080. mock_client.state.state = "RUNNING"
  1081. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1082. mock_pm.get_client.return_value = mock_client
  1083. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1084. assert response.status_code == 200
  1085. result = response.json()
  1086. assert result["total"] == 3
  1087. assert result["skipped_count"] == 1
  1088. assert result["is_printing"] is True
  1089. # Check objects have correct structure
  1090. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  1091. assert objects_by_id[100]["name"] == "Part A"
  1092. assert objects_by_id[100]["skipped"] is False
  1093. assert objects_by_id[200]["name"] == "Part B"
  1094. assert objects_by_id[200]["skipped"] is True
  1095. assert objects_by_id[300]["name"] == "Part C"
  1096. assert objects_by_id[300]["skipped"] is False
  1097. # ========================================================================
  1098. # Skip objects endpoint
  1099. # ========================================================================
  1100. @pytest.mark.asyncio
  1101. @pytest.mark.integration
  1102. async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):
  1103. """Verify objects list includes position data when available."""
  1104. printer = await printer_factory(name="Printing Printer")
  1105. # New format with position data
  1106. mock_client = MagicMock()
  1107. mock_client.state.printable_objects = {
  1108. 100: {"name": "Part A", "x": 50.0, "y": 100.0},
  1109. 200: {"name": "Part B", "x": 150.0, "y": 100.0},
  1110. }
  1111. mock_client.state.skipped_objects = []
  1112. mock_client.state.state = "RUNNING"
  1113. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1114. mock_pm.get_client.return_value = mock_client
  1115. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1116. assert response.status_code == 200
  1117. result = response.json()
  1118. assert result["total"] == 2
  1119. # Check objects have position data
  1120. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  1121. assert objects_by_id[100]["name"] == "Part A"
  1122. assert objects_by_id[100]["x"] == 50.0
  1123. assert objects_by_id[100]["y"] == 100.0
  1124. assert objects_by_id[200]["name"] == "Part B"
  1125. assert objects_by_id[200]["x"] == 150.0
  1126. assert objects_by_id[200]["y"] == 100.0
  1127. # ========================================================================
  1128. # Skip objects endpoint
  1129. # ========================================================================
  1130. @pytest.mark.asyncio
  1131. @pytest.mark.integration
  1132. async def test_skip_objects_not_found(self, async_client: AsyncClient):
  1133. """Verify 404 for non-existent printer."""
  1134. response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100])
  1135. assert response.status_code == 404
  1136. @pytest.mark.asyncio
  1137. @pytest.mark.integration
  1138. async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  1139. """Verify error when printer is not connected."""
  1140. printer = await printer_factory(name="Disconnected Printer")
  1141. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1142. mock_pm.get_client.return_value = None
  1143. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1144. assert response.status_code == 400
  1145. assert "not connected" in response.json()["detail"].lower()
  1146. @pytest.mark.asyncio
  1147. @pytest.mark.integration
  1148. async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):
  1149. """Verify error when no object IDs provided."""
  1150. printer = await printer_factory(name="Printing Printer")
  1151. mock_client = MagicMock()
  1152. mock_client.state.printable_objects = {100: "Part A"}
  1153. mock_client.state.skipped_objects = []
  1154. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1155. mock_pm.get_client.return_value = mock_client
  1156. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[])
  1157. assert response.status_code == 400
  1158. assert "no object" in response.json()["detail"].lower()
  1159. @pytest.mark.asyncio
  1160. @pytest.mark.integration
  1161. async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):
  1162. """Verify error when object ID doesn't exist."""
  1163. printer = await printer_factory(name="Printing Printer")
  1164. mock_client = MagicMock()
  1165. mock_client.state.printable_objects = {100: "Part A"}
  1166. mock_client.state.skipped_objects = []
  1167. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1168. mock_pm.get_client.return_value = mock_client
  1169. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999])
  1170. assert response.status_code == 400
  1171. assert "invalid" in response.json()["detail"].lower()
  1172. @pytest.mark.asyncio
  1173. @pytest.mark.integration
  1174. async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):
  1175. """Verify successful skip objects request."""
  1176. printer = await printer_factory(name="Printing Printer")
  1177. mock_client = MagicMock()
  1178. mock_client.state.printable_objects = {100: "Part A", 200: "Part B"}
  1179. mock_client.state.skipped_objects = []
  1180. mock_client.skip_objects.return_value = True
  1181. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1182. mock_pm.get_client.return_value = mock_client
  1183. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1184. assert response.status_code == 200
  1185. result = response.json()
  1186. assert result["success"] is True
  1187. assert 100 in result["skipped_objects"]
  1188. mock_client.skip_objects.assert_called_once_with([100])
  1189. @pytest.mark.asyncio
  1190. @pytest.mark.integration
  1191. async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):
  1192. """Verify skipping multiple objects at once."""
  1193. printer = await printer_factory(name="Printing Printer")
  1194. mock_client = MagicMock()
  1195. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  1196. mock_client.state.skipped_objects = []
  1197. mock_client.skip_objects.return_value = True
  1198. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1199. mock_pm.get_client.return_value = mock_client
  1200. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200])
  1201. assert response.status_code == 200
  1202. result = response.json()
  1203. assert result["success"] is True
  1204. assert 100 in result["skipped_objects"]
  1205. assert 200 in result["skipped_objects"]
  1206. mock_client.skip_objects.assert_called_once_with([100, 200])
  1207. class TestChamberLightAPI:
  1208. """Integration tests for chamber light control endpoint."""
  1209. @pytest.mark.asyncio
  1210. @pytest.mark.integration
  1211. async def test_chamber_light_not_found(self, async_client: AsyncClient):
  1212. """Verify 404 for non-existent printer."""
  1213. response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true")
  1214. assert response.status_code == 404
  1215. @pytest.mark.asyncio
  1216. @pytest.mark.integration
  1217. async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):
  1218. """Verify error when printer is not connected."""
  1219. printer = await printer_factory(name="Disconnected Printer")
  1220. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1221. mock_pm.get_client.return_value = None
  1222. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1223. assert response.status_code == 400
  1224. assert "not connected" in response.json()["detail"].lower()
  1225. @pytest.mark.asyncio
  1226. @pytest.mark.integration
  1227. async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):
  1228. """Verify successful chamber light on request."""
  1229. printer = await printer_factory(name="Test Printer")
  1230. mock_client = MagicMock()
  1231. mock_client.set_chamber_light.return_value = True
  1232. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1233. mock_pm.get_client.return_value = mock_client
  1234. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1235. assert response.status_code == 200
  1236. result = response.json()
  1237. assert result["success"] is True
  1238. assert "on" in result["message"].lower()
  1239. mock_client.set_chamber_light.assert_called_once_with(True)
  1240. @pytest.mark.asyncio
  1241. @pytest.mark.integration
  1242. async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):
  1243. """Verify successful chamber light off request."""
  1244. printer = await printer_factory(name="Test Printer")
  1245. mock_client = MagicMock()
  1246. mock_client.set_chamber_light.return_value = True
  1247. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1248. mock_pm.get_client.return_value = mock_client
  1249. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false")
  1250. assert response.status_code == 200
  1251. result = response.json()
  1252. assert result["success"] is True
  1253. assert "off" in result["message"].lower()
  1254. mock_client.set_chamber_light.assert_called_once_with(False)
  1255. @pytest.mark.asyncio
  1256. @pytest.mark.integration
  1257. async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):
  1258. """Verify error handling when chamber light control fails."""
  1259. printer = await printer_factory(name="Test Printer")
  1260. mock_client = MagicMock()
  1261. mock_client.set_chamber_light.return_value = False
  1262. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1263. mock_pm.get_client.return_value = mock_client
  1264. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1265. assert response.status_code == 500
  1266. assert "failed" in response.json()["detail"].lower()
  1267. class TestAirductModeAPI:
  1268. """Integration tests for the airduct mode endpoint (P2S/H2*)."""
  1269. @pytest.mark.asyncio
  1270. @pytest.mark.integration
  1271. async def test_invalid_mode_rejected(self, async_client: AsyncClient, printer_factory):
  1272. printer = await printer_factory(name="P", model="P2S")
  1273. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=foo")
  1274. assert response.status_code == 400
  1275. @pytest.mark.asyncio
  1276. @pytest.mark.integration
  1277. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  1278. printer = await printer_factory(name="P", model="P2S")
  1279. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1280. mock_pm.get_client.return_value = None
  1281. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1282. assert response.status_code == 400
  1283. @pytest.mark.asyncio
  1284. @pytest.mark.integration
  1285. async def test_cooling_success(self, async_client: AsyncClient, printer_factory):
  1286. printer = await printer_factory(name="P", model="P2S")
  1287. mock_client = MagicMock()
  1288. mock_client.set_airduct_mode.return_value = True
  1289. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1290. mock_pm.get_client.return_value = mock_client
  1291. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1292. assert response.status_code == 200
  1293. assert response.json()["success"] is True
  1294. mock_client.set_airduct_mode.assert_called_once_with("cooling")
  1295. @pytest.mark.asyncio
  1296. @pytest.mark.integration
  1297. async def test_heating_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  1298. printer = await printer_factory(name="P", model="P2S")
  1299. mock_client = MagicMock()
  1300. mock_client.set_airduct_mode.return_value = False
  1301. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1302. mock_pm.get_client.return_value = mock_client
  1303. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=heating")
  1304. assert response.status_code == 500
  1305. class TestClearHMSErrorsAPI:
  1306. """Integration tests for clear HMS errors endpoint."""
  1307. @pytest.mark.asyncio
  1308. @pytest.mark.integration
  1309. async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
  1310. """Verify 404 for non-existent printer."""
  1311. response = await async_client.post("/api/v1/printers/99999/hms/clear")
  1312. assert response.status_code == 404
  1313. @pytest.mark.asyncio
  1314. @pytest.mark.integration
  1315. async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
  1316. """Verify error when printer is not connected."""
  1317. printer = await printer_factory(name="Disconnected Printer")
  1318. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1319. mock_pm.get_client.return_value = None
  1320. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1321. assert response.status_code == 400
  1322. assert "not connected" in response.json()["detail"].lower()
  1323. @pytest.mark.asyncio
  1324. @pytest.mark.integration
  1325. async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
  1326. """Verify successful clear HMS errors request."""
  1327. printer = await printer_factory(name="Test Printer")
  1328. mock_client = MagicMock()
  1329. mock_client.clear_hms_errors.return_value = True
  1330. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1331. mock_pm.get_client.return_value = mock_client
  1332. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1333. assert response.status_code == 200
  1334. result = response.json()
  1335. assert result["success"] is True
  1336. assert "cleared" in result["message"].lower()
  1337. mock_client.clear_hms_errors.assert_called_once()
  1338. @pytest.mark.asyncio
  1339. @pytest.mark.integration
  1340. async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
  1341. """Verify error handling when clear HMS errors fails."""
  1342. printer = await printer_factory(name="Test Printer")
  1343. mock_client = MagicMock()
  1344. mock_client.clear_hms_errors.return_value = False
  1345. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1346. mock_pm.get_client.return_value = mock_client
  1347. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1348. assert response.status_code == 500
  1349. assert "failed" in response.json()["detail"].lower()
  1350. class TestExecuteHMSActionAPI:
  1351. """Integration tests for the /hms/execute-action endpoint (#1743).
  1352. Mirrors TestClearHMSErrorsAPI's shape — the two routes share the same
  1353. permission gate, the same DB-lookup + client-existence flow, and the
  1354. same dispatch-then-return-success pattern. The body-validation cases
  1355. add coverage that the bare clear endpoint doesn't need.
  1356. """
  1357. _VALID_BODY = {
  1358. "print_error": "03008070",
  1359. "action": "OK_BUTTON",
  1360. "job_id": None,
  1361. }
  1362. @pytest.mark.asyncio
  1363. @pytest.mark.integration
  1364. async def test_execute_hms_action_not_found(self, async_client: AsyncClient):
  1365. """404 for a printer id that doesn't exist."""
  1366. response = await async_client.post("/api/v1/printers/99999/hms/execute-action", json=self._VALID_BODY)
  1367. assert response.status_code == 404
  1368. @pytest.mark.asyncio
  1369. @pytest.mark.integration
  1370. async def test_execute_hms_action_not_connected(self, async_client: AsyncClient, printer_factory):
  1371. """400 when the printer record exists but the MQTT client is offline."""
  1372. printer = await printer_factory(name="Disconnected Printer")
  1373. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1374. mock_pm.get_client.return_value = None
  1375. response = await async_client.post(
  1376. f"/api/v1/printers/{printer.id}/hms/execute-action", json=self._VALID_BODY
  1377. )
  1378. assert response.status_code == 400
  1379. assert "not connected" in response.json()["detail"].lower()
  1380. @pytest.mark.asyncio
  1381. @pytest.mark.integration
  1382. async def test_execute_hms_action_success(self, async_client: AsyncClient, printer_factory):
  1383. """200 happy path — dispatcher returns True AND printer state moves
  1384. within the ack-wait window. The state delta is the firmware's only
  1385. proof that the command landed (publish success is necessary but not
  1386. sufficient; see #1830 §(3))."""
  1387. printer = await printer_factory(name="Test Printer")
  1388. mock_client = MagicMock()
  1389. # Pre-action state — paused with a fault.
  1390. mock_client.state.state = "PAUSE"
  1391. mock_client.state.print_error = 0x05008051
  1392. mock_client.state.hms_errors = [object()]
  1393. def _act(*_a, **_kw):
  1394. # Simulate the printer accepting the command and clearing the fault
  1395. # by the time the ack-wait expires.
  1396. mock_client.state.state = "FAILED"
  1397. mock_client.state.print_error = 0
  1398. mock_client.state.hms_errors = []
  1399. return True
  1400. mock_client.execute_hms_action.side_effect = _act
  1401. with (
  1402. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1403. patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
  1404. ):
  1405. mock_pm.get_client.return_value = mock_client
  1406. body = {"print_error": "07008029", "action": "FILAMENT_EXTRUDED", "job_id": "task-7"}
  1407. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
  1408. assert response.status_code == 200
  1409. result = response.json()
  1410. assert result["success"] is True
  1411. assert "executed" in result["message"].lower()
  1412. # Body args reach the client method in (print_error, action, job_id) order.
  1413. mock_client.execute_hms_action.assert_called_once_with("07008029", "FILAMENT_EXTRUDED", "task-7")
  1414. @pytest.mark.asyncio
  1415. @pytest.mark.integration
  1416. async def test_execute_hms_action_dispatcher_failure(self, async_client: AsyncClient, printer_factory):
  1417. """400 when the dispatcher returns False (unknown action, mid-flight disconnect)."""
  1418. printer = await printer_factory(name="Test Printer")
  1419. mock_client = MagicMock()
  1420. mock_client.execute_hms_action.return_value = False
  1421. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1422. mock_pm.get_client.return_value = mock_client
  1423. response = await async_client.post(
  1424. f"/api/v1/printers/{printer.id}/hms/execute-action", json=self._VALID_BODY
  1425. )
  1426. assert response.status_code == 400
  1427. assert "failed" in response.json()["detail"].lower()
  1428. @pytest.mark.asyncio
  1429. @pytest.mark.integration
  1430. async def test_execute_hms_action_no_printer_ack_returns_502(self, async_client: AsyncClient, printer_factory):
  1431. """502 when publish succeeded but printer state didn't move within the
  1432. ack-wait window. This is the silent-rejection failure mode #1830
  1433. identifies: the broker ACKs the publish at QoS 1 but the firmware
  1434. drops the command (err mismatch, wrong shape, state mismatch).
  1435. Surfacing this as 502 instead of 200 stops the UI from claiming
  1436. success while the modal sticks."""
  1437. printer = await printer_factory(name="Test Printer")
  1438. mock_client = MagicMock()
  1439. mock_client.state.state = "PAUSE"
  1440. mock_client.state.print_error = 0x05008051
  1441. mock_client.state.hms_errors = [object()]
  1442. mock_client.execute_hms_action.return_value = True # publish "succeeded"
  1443. # Crucially: state does NOT change → ack-wait detects no movement.
  1444. with (
  1445. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1446. patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
  1447. ):
  1448. mock_pm.get_client.return_value = mock_client
  1449. response = await async_client.post(
  1450. f"/api/v1/printers/{printer.id}/hms/execute-action", json=self._VALID_BODY
  1451. )
  1452. assert response.status_code == 502
  1453. assert "acknowledge" in response.json()["detail"].lower()
  1454. @pytest.mark.asyncio
  1455. @pytest.mark.integration
  1456. async def test_execute_hms_action_accepts_16_char_full_code(self, async_client: AsyncClient, printer_factory):
  1457. """200 for a 16-char full_code (hms[]-array-sourced fault). The
  1458. schema's relaxed pattern allows both 8-char (print_error) and
  1459. 16-char (hms[]) shapes."""
  1460. printer = await printer_factory(name="Test Printer")
  1461. mock_client = MagicMock()
  1462. mock_client.state.state = "RUNNING"
  1463. mock_client.state.print_error = 0
  1464. mock_client.state.hms_errors = [object()]
  1465. def _act(*_a, **_kw):
  1466. mock_client.state.hms_errors = []
  1467. return True
  1468. mock_client.execute_hms_action.side_effect = _act
  1469. with (
  1470. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1471. patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
  1472. ):
  1473. mock_pm.get_client.return_value = mock_client
  1474. body = {"print_error": "0C00030000020010", "action": "IGNORE_RESUME"}
  1475. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
  1476. assert response.status_code == 200
  1477. mock_client.execute_hms_action.assert_called_once_with("0C00030000020010", "IGNORE_RESUME", None)
  1478. @pytest.mark.asyncio
  1479. @pytest.mark.integration
  1480. async def test_execute_hms_action_rejects_malformed_print_error(self, async_client: AsyncClient, printer_factory):
  1481. """422 when print_error fails the relaxed pattern (8 OR 16 hex chars).
  1482. Lengths in between (9-15) and outside (7, 17+) are invalid; stray
  1483. input can't reach the dispatcher's match statement."""
  1484. printer = await printer_factory(name="Test Printer")
  1485. bad_bodies = [
  1486. {"print_error": "0300_8070", "action": "OK_BUTTON"}, # underscore
  1487. {"print_error": "0300807", "action": "OK_BUTTON"}, # 7 chars (too short)
  1488. {"print_error": "030080700", "action": "OK_BUTTON"}, # 9 chars (between)
  1489. {"print_error": "030080700300807", "action": "OK_BUTTON"}, # 15 chars (between)
  1490. {"print_error": "0300807003008070A", "action": "OK_BUTTON"}, # 17 chars (too long)
  1491. {"print_error": "0300GGGG", "action": "OK_BUTTON"}, # non-hex
  1492. ]
  1493. for body in bad_bodies:
  1494. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
  1495. assert response.status_code == 422, body
  1496. def _build_h2d_state(*, ams_id: int = 0, tray_id: int = 2, cali_idx: int = 5):
  1497. """Build a MagicMock PrinterState for an H2D printer with a single BL spool tray.
  1498. Used by both TestApplyPaAfterRefresh (Phase 13 P13-T-BE-1) and the K-profile
  1499. persistence tests below. The tray data passes is_bambu_tag (32-char non-zero
  1500. tray_uuid + non-empty tray_info_idx).
  1501. """
  1502. nozzle = MagicMock(nozzle_diameter="0.4")
  1503. state = MagicMock()
  1504. state.nozzles = [nozzle]
  1505. state.ams_extruder_map = {"0": 0}
  1506. state.raw_data = {
  1507. "ams": [
  1508. {
  1509. "id": ams_id,
  1510. "tray": [
  1511. {
  1512. "id": tray_id,
  1513. "tray_type": "PLA",
  1514. "tag_uid": "AABBCC1122334400",
  1515. "tray_uuid": "11223344556677880011223344556677",
  1516. "tray_info_idx": "GFL05",
  1517. "cali_idx": cali_idx,
  1518. }
  1519. ],
  1520. }
  1521. ]
  1522. }
  1523. return state
  1524. def _patch_async_session_to(db_session):
  1525. """Patch backend.app.core.database.async_session so calls inside the function
  1526. under test reuse the test fixture's db_session.
  1527. `_apply_pa_after_refresh` lazy-imports `from backend.app.core.database import
  1528. async_session` at runtime (line 2849). When we patch the source module
  1529. before the call, the lazy import picks up the patched object.
  1530. Returns the patch context manager; use as `with _patch_async_session_to(db_session):`.
  1531. Pattern verified against test_print_lifecycle.py:38-42.
  1532. """
  1533. cm = AsyncMock()
  1534. cm.__aenter__ = AsyncMock(return_value=db_session)
  1535. cm.__aexit__ = AsyncMock(return_value=None)
  1536. return patch("backend.app.core.database.async_session", return_value=cm)
  1537. class TestApplyPaAfterRefresh:
  1538. """Phase 13 P13-T-BE-1: _apply_pa_after_refresh K-profile cascade.
  1539. Verifies the 3-stage cascade (local SpoolKProfile → Spoolman SpoolmanKProfile
  1540. → live tray.cali_idx fallback) and the Bug A regression (kp.extruder, not
  1541. kp.extruder_id, after the Phase 13 P13-2a fix).
  1542. `_apply_pa_after_refresh` is a free function spawned via asyncio.create_task
  1543. from the /ams-refresh endpoint. Tests call it directly because awaiting the
  1544. spawned task in an HTTP test would require sleeping past the 5-second guard
  1545. that delays MQTT until RFID re-read finishes.
  1546. """
  1547. @pytest.mark.asyncio
  1548. @pytest.mark.integration
  1549. async def test_local_kp_match_sends_stored_cali_idx(self, db_session, printer_factory):
  1550. """Local SpoolAssignment + matching SpoolKProfile → stored cali_idx wins."""
  1551. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1552. from backend.app.models.spool import Spool
  1553. from backend.app.models.spool_assignment import SpoolAssignment
  1554. from backend.app.models.spool_k_profile import SpoolKProfile
  1555. printer = await printer_factory()
  1556. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  1557. db_session.add(spool)
  1558. await db_session.flush()
  1559. db_session.add(
  1560. SpoolAssignment(
  1561. spool_id=spool.id,
  1562. printer_id=printer.id,
  1563. ams_id=0,
  1564. tray_id=2,
  1565. )
  1566. )
  1567. db_session.add(
  1568. SpoolKProfile(
  1569. spool_id=spool.id,
  1570. printer_id=printer.id,
  1571. extruder=0,
  1572. nozzle_diameter="0.4",
  1573. k_value=0.025,
  1574. cali_idx=42,
  1575. )
  1576. )
  1577. await db_session.commit()
  1578. mock_client = MagicMock()
  1579. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1580. state = _build_h2d_state(cali_idx=5)
  1581. with (
  1582. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1583. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1584. _patch_async_session_to(db_session),
  1585. ):
  1586. mock_pm.get_client.return_value = mock_client
  1587. mock_pm.get_status.return_value = state
  1588. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1589. mock_client.extrusion_cali_sel.assert_called_once()
  1590. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1591. assert kwargs["cali_idx"] == 42 # stored profile, not 5 (live)
  1592. @pytest.mark.asyncio
  1593. @pytest.mark.integration
  1594. async def test_local_no_kp_uses_live_cali_idx(self, db_session, printer_factory):
  1595. """Local SpoolAssignment but no matching SpoolKProfile → live cali_idx (Stage 3)."""
  1596. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1597. from backend.app.models.spool import Spool
  1598. from backend.app.models.spool_assignment import SpoolAssignment
  1599. printer = await printer_factory()
  1600. spool = Spool(material="PLA")
  1601. db_session.add(spool)
  1602. await db_session.flush()
  1603. db_session.add(
  1604. SpoolAssignment(
  1605. spool_id=spool.id,
  1606. printer_id=printer.id,
  1607. ams_id=0,
  1608. tray_id=2,
  1609. )
  1610. )
  1611. await db_session.commit()
  1612. mock_client = MagicMock()
  1613. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1614. state = _build_h2d_state(cali_idx=5)
  1615. with (
  1616. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1617. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1618. _patch_async_session_to(db_session),
  1619. ):
  1620. mock_pm.get_client.return_value = mock_client
  1621. mock_pm.get_status.return_value = state
  1622. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1623. mock_client.extrusion_cali_sel.assert_called_once()
  1624. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1625. assert kwargs["cali_idx"] == 5 # live tray.cali_idx fallback
  1626. @pytest.mark.asyncio
  1627. @pytest.mark.integration
  1628. async def test_spoolman_kp_when_no_local(self, db_session, printer_factory):
  1629. """No local assignment + Spoolman SlotAssignment + SpoolmanKProfile → Spoolman cali_idx."""
  1630. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1631. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  1632. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1633. printer = await printer_factory()
  1634. db_session.add(
  1635. SpoolmanSlotAssignment(
  1636. printer_id=printer.id,
  1637. ams_id=0,
  1638. tray_id=2,
  1639. spoolman_spool_id=99,
  1640. )
  1641. )
  1642. db_session.add(
  1643. SpoolmanKProfile(
  1644. spoolman_spool_id=99,
  1645. printer_id=printer.id,
  1646. extruder=0,
  1647. nozzle_diameter="0.4",
  1648. k_value=0.030,
  1649. cali_idx=77,
  1650. )
  1651. )
  1652. await db_session.commit()
  1653. mock_client = MagicMock()
  1654. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1655. state = _build_h2d_state(cali_idx=5)
  1656. with (
  1657. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1658. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1659. _patch_async_session_to(db_session),
  1660. ):
  1661. mock_pm.get_client.return_value = mock_client
  1662. mock_pm.get_status.return_value = state
  1663. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1664. mock_client.extrusion_cali_sel.assert_called_once()
  1665. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1666. assert kwargs["cali_idx"] == 77 # Spoolman stored profile
  1667. @pytest.mark.asyncio
  1668. @pytest.mark.integration
  1669. async def test_spoolman_no_kp_uses_live(self, db_session, printer_factory):
  1670. """Spoolman SlotAssignment but no SpoolmanKProfile → live cali_idx (Stage 3)."""
  1671. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1672. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1673. printer = await printer_factory()
  1674. db_session.add(
  1675. SpoolmanSlotAssignment(
  1676. printer_id=printer.id,
  1677. ams_id=0,
  1678. tray_id=2,
  1679. spoolman_spool_id=99,
  1680. )
  1681. )
  1682. await db_session.commit()
  1683. mock_client = MagicMock()
  1684. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1685. state = _build_h2d_state(cali_idx=5)
  1686. with (
  1687. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1688. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1689. _patch_async_session_to(db_session),
  1690. ):
  1691. mock_pm.get_client.return_value = mock_client
  1692. mock_pm.get_status.return_value = state
  1693. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1694. mock_client.extrusion_cali_sel.assert_called_once()
  1695. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  1696. @pytest.mark.asyncio
  1697. @pytest.mark.integration
  1698. async def test_no_assignment_uses_live(self, db_session, printer_factory):
  1699. """No assignment of any kind + live cali_idx >= 0 → live fallback."""
  1700. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1701. printer = await printer_factory()
  1702. mock_client = MagicMock()
  1703. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1704. state = _build_h2d_state(cali_idx=5)
  1705. with (
  1706. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1707. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1708. _patch_async_session_to(db_session),
  1709. ):
  1710. mock_pm.get_client.return_value = mock_client
  1711. mock_pm.get_status.return_value = state
  1712. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1713. mock_client.extrusion_cali_sel.assert_called_once()
  1714. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  1715. @pytest.mark.asyncio
  1716. @pytest.mark.integration
  1717. async def test_negative_live_cali_idx_skipped(self, db_session, printer_factory):
  1718. """No assignment + live cali_idx=-1 → no MQTT call (invalid live value)."""
  1719. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1720. printer = await printer_factory()
  1721. mock_client = MagicMock()
  1722. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1723. state = _build_h2d_state(cali_idx=-1)
  1724. with (
  1725. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1726. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1727. _patch_async_session_to(db_session),
  1728. ):
  1729. mock_pm.get_client.return_value = mock_client
  1730. mock_pm.get_status.return_value = state
  1731. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1732. mock_client.extrusion_cali_sel.assert_not_called()
  1733. @pytest.mark.asyncio
  1734. @pytest.mark.integration
  1735. async def test_no_assignment_no_live_cali_idx_no_call(self, db_session, printer_factory):
  1736. """No assignment of any kind AND no live cali_idx in tray → no MQTT call.
  1737. Distinct from test_negative_live_cali_idx_skipped: that test has
  1738. cali_idx=-1 in raw_data; this one omits the field entirely (returns
  1739. None from .get("cali_idx")). Both must result in no MQTT call.
  1740. """
  1741. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1742. printer = await printer_factory()
  1743. mock_client = MagicMock()
  1744. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1745. # State with NO cali_idx field on tray at all
  1746. nozzle = MagicMock(nozzle_diameter="0.4")
  1747. state = MagicMock()
  1748. state.nozzles = [nozzle]
  1749. state.ams_extruder_map = {"0": 0}
  1750. state.raw_data = {
  1751. "ams": [
  1752. {
  1753. "id": 0,
  1754. "tray": [
  1755. {
  1756. "id": 2,
  1757. "tray_type": "PLA",
  1758. "tag_uid": "AABBCC1122334400",
  1759. "tray_uuid": "11223344556677880011223344556677",
  1760. "tray_info_idx": "GFL05",
  1761. # cali_idx field intentionally omitted
  1762. }
  1763. ],
  1764. }
  1765. ]
  1766. }
  1767. with (
  1768. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1769. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1770. _patch_async_session_to(db_session),
  1771. ):
  1772. mock_pm.get_client.return_value = mock_client
  1773. mock_pm.get_status.return_value = state
  1774. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1775. mock_client.extrusion_cali_sel.assert_not_called()
  1776. @pytest.mark.asyncio
  1777. @pytest.mark.integration
  1778. async def test_extruder_mismatch_uses_kp_as_fallback(self, db_session, printer_factory):
  1779. """K-profile for extruder=1 but slot is extruder=0 → no exact match,
  1780. but the kp is used as extruder-agnostic fallback rather than dropped.
  1781. Hard-skipping on extruder mismatch was the previous behavior; in
  1782. practice it caused stored K-profiles to be silently ignored whenever
  1783. the AMS-extruder mapping had shifted (or when only one of the two
  1784. extruders was ever calibrated for a given spool). The cascade now
  1785. prefers an exact extruder match but falls back to any matching kp
  1786. for the same printer + nozzle.
  1787. """
  1788. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1789. from backend.app.models.spool import Spool
  1790. from backend.app.models.spool_assignment import SpoolAssignment
  1791. from backend.app.models.spool_k_profile import SpoolKProfile
  1792. printer = await printer_factory()
  1793. spool = Spool(material="PLA")
  1794. db_session.add(spool)
  1795. await db_session.flush()
  1796. db_session.add(
  1797. SpoolAssignment(
  1798. spool_id=spool.id,
  1799. printer_id=printer.id,
  1800. ams_id=0,
  1801. tray_id=2,
  1802. )
  1803. )
  1804. # K-profile is for extruder=1, but slot's ams_extruder_map["0"]=0
  1805. db_session.add(
  1806. SpoolKProfile(
  1807. spool_id=spool.id,
  1808. printer_id=printer.id,
  1809. extruder=1,
  1810. nozzle_diameter="0.4",
  1811. k_value=0.025,
  1812. cali_idx=42,
  1813. )
  1814. )
  1815. await db_session.commit()
  1816. mock_client = MagicMock()
  1817. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1818. state = _build_h2d_state(cali_idx=5)
  1819. with (
  1820. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1821. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1822. _patch_async_session_to(db_session),
  1823. ):
  1824. mock_pm.get_client.return_value = mock_client
  1825. mock_pm.get_status.return_value = state
  1826. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1827. # No exact extruder match, but the stored kp wins as the
  1828. # extruder-agnostic fallback over live cali_idx=5.
  1829. mock_client.extrusion_cali_sel.assert_called_once()
  1830. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1831. @pytest.mark.asyncio
  1832. @pytest.mark.integration
  1833. async def test_extruder_exact_match_preferred_over_fallback(
  1834. self,
  1835. db_session,
  1836. printer_factory,
  1837. ):
  1838. """When two kp rows exist, one with matching extruder and one without,
  1839. the exact-extruder kp wins (extruder-agnostic fallback only fires when
  1840. no exact match exists).
  1841. """
  1842. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1843. from backend.app.models.spool import Spool
  1844. from backend.app.models.spool_assignment import SpoolAssignment
  1845. from backend.app.models.spool_k_profile import SpoolKProfile
  1846. printer = await printer_factory()
  1847. spool = Spool(material="PLA")
  1848. db_session.add(spool)
  1849. await db_session.flush()
  1850. db_session.add(
  1851. SpoolAssignment(
  1852. spool_id=spool.id,
  1853. printer_id=printer.id,
  1854. ams_id=0,
  1855. tray_id=2,
  1856. )
  1857. )
  1858. # Two kp rows: extruder=1 (mismatch w/ slot extruder=0) and extruder=0 (exact)
  1859. db_session.add(
  1860. SpoolKProfile(
  1861. spool_id=spool.id,
  1862. printer_id=printer.id,
  1863. extruder=1,
  1864. nozzle_diameter="0.4",
  1865. k_value=0.030,
  1866. cali_idx=99,
  1867. )
  1868. )
  1869. db_session.add(
  1870. SpoolKProfile(
  1871. spool_id=spool.id,
  1872. printer_id=printer.id,
  1873. extruder=0,
  1874. nozzle_diameter="0.4",
  1875. k_value=0.025,
  1876. cali_idx=42,
  1877. )
  1878. )
  1879. await db_session.commit()
  1880. mock_client = MagicMock()
  1881. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1882. state = _build_h2d_state(cali_idx=5)
  1883. with (
  1884. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1885. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1886. _patch_async_session_to(db_session),
  1887. ):
  1888. mock_pm.get_client.return_value = mock_client
  1889. mock_pm.get_status.return_value = state
  1890. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1891. # Exact-extruder=0 kp wins (cali_idx=42), not the extruder=1 fallback (99)
  1892. mock_client.extrusion_cali_sel.assert_called_once()
  1893. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1894. @pytest.mark.asyncio
  1895. @pytest.mark.integration
  1896. async def test_regression_bug_a_kp_extruder_attr(self, db_session, printer_factory):
  1897. """Regression test for Phase 13 P13-2a Bug A.
  1898. Pre-fix Z.2910 used `kp.extruder_id` (AttributeError on SpoolKProfile,
  1899. silently swallowed by outer try/except). On dual-nozzle printers with
  1900. slot_extruder != None this caused the K-profile match loop to crash.
  1901. After P13-2a the field name is correct: `kp.extruder`.
  1902. """
  1903. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1904. from backend.app.models.spool import Spool
  1905. from backend.app.models.spool_assignment import SpoolAssignment
  1906. from backend.app.models.spool_k_profile import SpoolKProfile
  1907. printer = await printer_factory()
  1908. spool = Spool(material="PLA")
  1909. db_session.add(spool)
  1910. await db_session.flush()
  1911. db_session.add(
  1912. SpoolAssignment(
  1913. spool_id=spool.id,
  1914. printer_id=printer.id,
  1915. ams_id=0,
  1916. tray_id=2,
  1917. )
  1918. )
  1919. # extruder=0 matches slot_extruder=0 (from ams_extruder_map={"0":0})
  1920. db_session.add(
  1921. SpoolKProfile(
  1922. spool_id=spool.id,
  1923. printer_id=printer.id,
  1924. extruder=0,
  1925. nozzle_diameter="0.4",
  1926. k_value=0.025,
  1927. cali_idx=42,
  1928. )
  1929. )
  1930. await db_session.commit()
  1931. mock_client = MagicMock()
  1932. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1933. state = _build_h2d_state(cali_idx=5)
  1934. with (
  1935. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1936. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1937. _patch_async_session_to(db_session),
  1938. ):
  1939. mock_pm.get_client.return_value = mock_client
  1940. mock_pm.get_status.return_value = state
  1941. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1942. # If Bug A regressed (kp.extruder_id), the loop would AttributeError → silent fail
  1943. # → no extrusion_cali_sel call. Post-fix the loop matches and sends cali_idx=42.
  1944. mock_client.extrusion_cali_sel.assert_called_once()
  1945. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1946. @pytest.mark.asyncio
  1947. @pytest.mark.integration
  1948. async def test_tag_fallback_finds_spool_when_assignment_missing(
  1949. self,
  1950. db_session,
  1951. printer_factory,
  1952. ):
  1953. """Stage 1b regression for the maintainer's #2 reproducer on H2D:
  1954. reset slot, trigger re-read → slot ends up on the default K-profile
  1955. instead of the spool's stored profile.
  1956. Setup mirrors the bug:
  1957. - Spool has tray_uuid set (the RFID tag was registered earlier).
  1958. - SpoolKProfile exists for that spool with cali_idx=42.
  1959. - NO SpoolAssignment row — the reset deleted it before the re-read
  1960. triggered _apply_pa_after_refresh, and tag-auto-detect has not
  1961. re-created it yet within the 5 s sleep window.
  1962. - Live tray.cali_idx=5 (firmware-default after the RFID re-read).
  1963. Without Stage 1b the cascade falls through to Stage 3 and re-asserts
  1964. the firmware-default cali_idx=5. With Stage 1b it locates the spool by
  1965. the live tray's tray_uuid and applies the stored cali_idx=42.
  1966. """
  1967. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1968. from backend.app.models.spool import Spool
  1969. from backend.app.models.spool_k_profile import SpoolKProfile
  1970. printer = await printer_factory()
  1971. # Spool with tray_uuid matching the one _build_h2d_state puts on the tray
  1972. spool = Spool(
  1973. material="PLA",
  1974. color_name="Red",
  1975. rgba="FF0000FF",
  1976. tray_uuid="11223344556677880011223344556677",
  1977. tag_uid="AABBCC1122334400",
  1978. )
  1979. db_session.add(spool)
  1980. await db_session.flush()
  1981. # K-profile is bound to the spool, not to a slot
  1982. db_session.add(
  1983. SpoolKProfile(
  1984. spool_id=spool.id,
  1985. printer_id=printer.id,
  1986. extruder=0,
  1987. nozzle_diameter="0.4",
  1988. k_value=0.025,
  1989. cali_idx=42,
  1990. )
  1991. )
  1992. # NOTE: deliberately no SpoolAssignment — that's the bug condition.
  1993. await db_session.commit()
  1994. mock_client = MagicMock()
  1995. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1996. state = _build_h2d_state(cali_idx=5)
  1997. with (
  1998. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1999. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2000. _patch_async_session_to(db_session),
  2001. ):
  2002. mock_pm.get_client.return_value = mock_client
  2003. mock_pm.get_status.return_value = state
  2004. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2005. # Stage 1b should match the spool by tray_uuid → stored cali_idx=42 wins
  2006. # over live cali_idx=5. Pre-fix this would have been 5 (firmware default).
  2007. mock_client.extrusion_cali_sel.assert_called_once()
  2008. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  2009. @pytest.mark.asyncio
  2010. @pytest.mark.integration
  2011. async def test_tag_fallback_matches_by_tag_uid_when_uuid_zero(
  2012. self,
  2013. db_session,
  2014. printer_factory,
  2015. ):
  2016. """Stage 1b: when tray_uuid is the zero sentinel but tag_uid is real,
  2017. match by tag_uid. Older firmwares occasionally report a zero tray_uuid
  2018. right after RFID re-read while the tag_uid is already populated."""
  2019. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2020. from backend.app.models.spool import Spool
  2021. from backend.app.models.spool_k_profile import SpoolKProfile
  2022. printer = await printer_factory()
  2023. # Spool indexed by tag_uid, not tray_uuid
  2024. spool = Spool(material="PLA", tag_uid="AABBCC1122334400")
  2025. db_session.add(spool)
  2026. await db_session.flush()
  2027. db_session.add(
  2028. SpoolKProfile(
  2029. spool_id=spool.id,
  2030. printer_id=printer.id,
  2031. extruder=0,
  2032. nozzle_diameter="0.4",
  2033. k_value=0.025,
  2034. cali_idx=99,
  2035. )
  2036. )
  2037. await db_session.commit()
  2038. mock_client = MagicMock()
  2039. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2040. # Build a state where the tray reports a real tag_uid but a zero tray_uuid
  2041. # while still passing is_bambu_tag (tag_uid + tray_info_idx is sufficient).
  2042. state = _build_h2d_state(cali_idx=5)
  2043. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  2044. with (
  2045. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2046. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2047. _patch_async_session_to(db_session),
  2048. ):
  2049. mock_pm.get_client.return_value = mock_client
  2050. mock_pm.get_status.return_value = state
  2051. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2052. mock_client.extrusion_cali_sel.assert_called_once()
  2053. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 99
  2054. @pytest.mark.asyncio
  2055. @pytest.mark.integration
  2056. async def test_tag_fallback_skipped_when_zero_sentinels(
  2057. self,
  2058. db_session,
  2059. printer_factory,
  2060. ):
  2061. """Stage 1b: when both tray_uuid and tag_uid are zero sentinels, the
  2062. fallback must not match any spool (would otherwise pick up an
  2063. unrelated spool created with empty/zero tag fields). Falls through
  2064. to Stage 3 live cali_idx as before.
  2065. """
  2066. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2067. from backend.app.models.spool import Spool
  2068. from backend.app.models.spool_k_profile import SpoolKProfile
  2069. printer = await printer_factory()
  2070. # Decoy spool with no tag info — must NOT match
  2071. spool = Spool(material="PLA")
  2072. db_session.add(spool)
  2073. await db_session.flush()
  2074. db_session.add(
  2075. SpoolKProfile(
  2076. spool_id=spool.id,
  2077. printer_id=printer.id,
  2078. extruder=0,
  2079. nozzle_diameter="0.4",
  2080. k_value=0.025,
  2081. cali_idx=42,
  2082. )
  2083. )
  2084. await db_session.commit()
  2085. mock_client = MagicMock()
  2086. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2087. state = _build_h2d_state(cali_idx=7)
  2088. # Force both tag fields to the zero sentinels but keep tray_info_idx
  2089. # so is_bambu_tag still passes (preset present)
  2090. state.raw_data["ams"][0]["tray"][0]["tag_uid"] = "0000000000000000"
  2091. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  2092. with (
  2093. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2094. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2095. _patch_async_session_to(db_session),
  2096. ):
  2097. mock_pm.get_client.return_value = mock_client
  2098. mock_pm.get_status.return_value = state
  2099. # is_bambu_tag actually rejects both-zero + only-preset, so the
  2100. # function returns early. We just want to confirm we didn't blow
  2101. # up scanning for a tag-fallback spool.
  2102. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2103. # is_bambu_tag short-circuits early when both UID and UUID are zero,
  2104. # so no MQTT call should fire and the decoy spool's cali_idx=42 must
  2105. # NOT leak through.
  2106. if mock_client.extrusion_cali_sel.called:
  2107. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] != 42
  2108. class TestConfigureAmsSlotPersistsKProfile:
  2109. """Phase 13 P13-T-BE-2: configure_ams_slot persists K-profile to DB.
  2110. Pre-Phase-13 the endpoint sent extrusion_cali_sel via MQTT but never
  2111. recorded the choice in spool_k_profile / spoolman_k_profile, so the next
  2112. RFID re-read had no stored profile to apply.
  2113. """
  2114. @pytest.mark.asyncio
  2115. @pytest.mark.integration
  2116. async def test_writes_spoolman_kprofile_when_spoolman_assigned(
  2117. self,
  2118. async_client: AsyncClient,
  2119. db_session,
  2120. printer_factory,
  2121. ):
  2122. """SpoolmanSlotAssignment present → SpoolmanKProfile row created with cali_idx + k_value + name."""
  2123. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2124. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2125. printer = await printer_factory(model="H2D")
  2126. db_session.add(
  2127. SpoolmanSlotAssignment(
  2128. printer_id=printer.id,
  2129. ams_id=0,
  2130. tray_id=3,
  2131. spoolman_spool_id=216,
  2132. )
  2133. )
  2134. await db_session.commit()
  2135. mock_client = MagicMock()
  2136. mock_client.ams_set_filament_setting.return_value = True
  2137. mock_client.extrusion_cali_sel.return_value = True
  2138. mock_client.extrusion_cali_set.return_value = True
  2139. mock_client.request_status_update.return_value = True
  2140. mock_state = MagicMock()
  2141. mock_state.ams_extruder_map = {"0": 0}
  2142. mock_state.raw_data = {"ams": {"ams": []}}
  2143. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2144. mock_pm.get_client.return_value = mock_client
  2145. mock_pm.get_status.return_value = mock_state
  2146. response = await async_client.post(
  2147. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2148. params={
  2149. "tray_info_idx": "GFL05",
  2150. "tray_type": "PLA",
  2151. "tray_sub_brands": "Bambu PLA Metal",
  2152. "tray_color": "FF8800FF",
  2153. "nozzle_temp_min": 220,
  2154. "nozzle_temp_max": 240,
  2155. "cali_idx": 5,
  2156. "nozzle_diameter": "0.4",
  2157. "k_value": 0.022,
  2158. },
  2159. )
  2160. assert response.status_code == 200
  2161. kp_result = await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  2162. kp = kp_result.scalar_one_or_none()
  2163. assert kp is not None
  2164. assert kp.cali_idx == 5
  2165. assert kp.k_value == pytest.approx(0.022)
  2166. assert kp.extruder == 0
  2167. assert kp.nozzle_diameter == "0.4"
  2168. assert kp.name == "Bambu PLA Metal"
  2169. @pytest.mark.asyncio
  2170. @pytest.mark.integration
  2171. async def test_writes_spool_kprofile_when_local_assigned(
  2172. self,
  2173. async_client: AsyncClient,
  2174. db_session,
  2175. printer_factory,
  2176. ):
  2177. """Local SpoolAssignment present → SpoolKProfile row created."""
  2178. from backend.app.models.spool import Spool
  2179. from backend.app.models.spool_assignment import SpoolAssignment
  2180. from backend.app.models.spool_k_profile import SpoolKProfile
  2181. printer = await printer_factory(model="H2D")
  2182. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  2183. db_session.add(spool)
  2184. await db_session.flush()
  2185. db_session.add(
  2186. SpoolAssignment(
  2187. spool_id=spool.id,
  2188. printer_id=printer.id,
  2189. ams_id=0,
  2190. tray_id=3,
  2191. )
  2192. )
  2193. await db_session.commit()
  2194. spool_id = spool.id
  2195. mock_client = MagicMock()
  2196. mock_client.ams_set_filament_setting.return_value = True
  2197. mock_client.extrusion_cali_sel.return_value = True
  2198. mock_client.extrusion_cali_set.return_value = True
  2199. mock_client.request_status_update.return_value = True
  2200. mock_state = MagicMock()
  2201. mock_state.ams_extruder_map = {"0": 0}
  2202. mock_state.raw_data = {"ams": {"ams": []}}
  2203. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2204. mock_pm.get_client.return_value = mock_client
  2205. mock_pm.get_status.return_value = mock_state
  2206. response = await async_client.post(
  2207. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2208. params={
  2209. "tray_info_idx": "PFUSdev01",
  2210. "tray_type": "PLA",
  2211. "tray_sub_brands": "Devil Design PLA",
  2212. "tray_color": "FF0000FF",
  2213. "nozzle_temp_min": 220,
  2214. "nozzle_temp_max": 240,
  2215. "cali_idx": 7,
  2216. "nozzle_diameter": "0.4",
  2217. "k_value": 0.028,
  2218. },
  2219. )
  2220. assert response.status_code == 200
  2221. kp_result = await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2222. kp = kp_result.scalar_one_or_none()
  2223. assert kp is not None
  2224. assert kp.cali_idx == 7
  2225. assert kp.k_value == pytest.approx(0.028)
  2226. assert kp.extruder == 0
  2227. assert kp.nozzle_diameter == "0.4"
  2228. assert kp.name == "Devil Design PLA"
  2229. @pytest.mark.asyncio
  2230. @pytest.mark.integration
  2231. async def test_no_assignment_no_persist(
  2232. self,
  2233. async_client: AsyncClient,
  2234. db_session,
  2235. printer_factory,
  2236. ):
  2237. """No SpoolAssignment AND no SpoolmanSlotAssignment → no DB write, MQTT still sent."""
  2238. from backend.app.models.spool_k_profile import SpoolKProfile
  2239. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2240. printer = await printer_factory(model="H2D")
  2241. mock_client = MagicMock()
  2242. mock_client.ams_set_filament_setting.return_value = True
  2243. mock_client.extrusion_cali_sel.return_value = True
  2244. mock_client.request_status_update.return_value = True
  2245. mock_state = MagicMock()
  2246. mock_state.ams_extruder_map = {"0": 0}
  2247. mock_state.raw_data = {"ams": {"ams": []}}
  2248. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2249. mock_pm.get_client.return_value = mock_client
  2250. mock_pm.get_status.return_value = mock_state
  2251. response = await async_client.post(
  2252. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2253. params={
  2254. "tray_info_idx": "GFL05",
  2255. "tray_type": "PLA",
  2256. "tray_sub_brands": "PLA Basic",
  2257. "tray_color": "FFFFFFFF",
  2258. "nozzle_temp_min": 190,
  2259. "nozzle_temp_max": 230,
  2260. "cali_idx": 5,
  2261. "k_value": 0.020,
  2262. },
  2263. )
  2264. assert response.status_code == 200
  2265. # MQTT sent (was successful), but no DB writes
  2266. mock_client.extrusion_cali_sel.assert_called_once()
  2267. local_count = (await db_session.execute(select(SpoolKProfile))).scalars().all()
  2268. sm_count = (await db_session.execute(select(SpoolmanKProfile))).scalars().all()
  2269. assert len(local_count) == 0
  2270. assert len(sm_count) == 0
  2271. @pytest.mark.asyncio
  2272. @pytest.mark.integration
  2273. async def test_negative_cali_idx_no_persist(
  2274. self,
  2275. async_client: AsyncClient,
  2276. db_session,
  2277. printer_factory,
  2278. ):
  2279. """cali_idx=-1 (no profile selected) → no DB write even when assignment exists."""
  2280. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2281. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2282. printer = await printer_factory(model="H2D")
  2283. db_session.add(
  2284. SpoolmanSlotAssignment(
  2285. printer_id=printer.id,
  2286. ams_id=0,
  2287. tray_id=3,
  2288. spoolman_spool_id=216,
  2289. )
  2290. )
  2291. await db_session.commit()
  2292. mock_client = MagicMock()
  2293. mock_client.ams_set_filament_setting.return_value = True
  2294. mock_client.extrusion_cali_sel.return_value = True
  2295. mock_client.extrusion_cali_set.return_value = True
  2296. mock_client.request_status_update.return_value = True
  2297. mock_state = MagicMock()
  2298. mock_state.ams_extruder_map = {"0": 0}
  2299. mock_state.raw_data = {"ams": {"ams": []}}
  2300. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2301. mock_pm.get_client.return_value = mock_client
  2302. mock_pm.get_status.return_value = mock_state
  2303. response = await async_client.post(
  2304. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2305. params={
  2306. "tray_info_idx": "GFL05",
  2307. "tray_type": "PLA",
  2308. "tray_sub_brands": "PLA Basic",
  2309. "tray_color": "FFFFFFFF",
  2310. "nozzle_temp_min": 190,
  2311. "nozzle_temp_max": 230,
  2312. "cali_idx": -1,
  2313. "k_value": 0.0,
  2314. },
  2315. )
  2316. assert response.status_code == 200
  2317. sm_kps = (
  2318. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  2319. .scalars()
  2320. .all()
  2321. )
  2322. assert len(sm_kps) == 0 # cali_idx=-1 means "no profile" — don't write
  2323. @pytest.mark.asyncio
  2324. @pytest.mark.integration
  2325. async def test_zero_cali_idx_persists(
  2326. self,
  2327. async_client: AsyncClient,
  2328. db_session,
  2329. printer_factory,
  2330. ):
  2331. """cali_idx=0 is the first valid profile slot (NOT a sentinel for missing)."""
  2332. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2333. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2334. printer = await printer_factory(model="H2D")
  2335. db_session.add(
  2336. SpoolmanSlotAssignment(
  2337. printer_id=printer.id,
  2338. ams_id=0,
  2339. tray_id=3,
  2340. spoolman_spool_id=216,
  2341. )
  2342. )
  2343. await db_session.commit()
  2344. mock_client = MagicMock()
  2345. mock_client.ams_set_filament_setting.return_value = True
  2346. mock_client.extrusion_cali_sel.return_value = True
  2347. mock_client.request_status_update.return_value = True
  2348. mock_state = MagicMock()
  2349. mock_state.ams_extruder_map = {"0": 0}
  2350. mock_state.raw_data = {"ams": {"ams": []}}
  2351. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2352. mock_pm.get_client.return_value = mock_client
  2353. mock_pm.get_status.return_value = mock_state
  2354. response = await async_client.post(
  2355. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2356. params={
  2357. "tray_info_idx": "GFL05",
  2358. "tray_type": "PLA",
  2359. "tray_sub_brands": "PLA Basic",
  2360. "tray_color": "FFFFFFFF",
  2361. "nozzle_temp_min": 190,
  2362. "nozzle_temp_max": 230,
  2363. "cali_idx": 0,
  2364. "k_value": 0.020,
  2365. },
  2366. )
  2367. assert response.status_code == 200
  2368. kp = (
  2369. await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  2370. ).scalar_one_or_none()
  2371. assert kp is not None
  2372. assert kp.cali_idx == 0 # explicitly testing 0 is valid
  2373. @pytest.mark.asyncio
  2374. @pytest.mark.integration
  2375. async def test_upsert_idempotent(
  2376. self,
  2377. async_client: AsyncClient,
  2378. db_session,
  2379. printer_factory,
  2380. ):
  2381. """Repeated POSTs update the same row (UNIQUE on spool_id+printer+extruder+nozzle_diameter)."""
  2382. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2383. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2384. printer = await printer_factory(model="H2D")
  2385. db_session.add(
  2386. SpoolmanSlotAssignment(
  2387. printer_id=printer.id,
  2388. ams_id=0,
  2389. tray_id=3,
  2390. spoolman_spool_id=216,
  2391. )
  2392. )
  2393. await db_session.commit()
  2394. mock_client = MagicMock()
  2395. mock_client.ams_set_filament_setting.return_value = True
  2396. mock_client.extrusion_cali_sel.return_value = True
  2397. mock_client.request_status_update.return_value = True
  2398. mock_state = MagicMock()
  2399. mock_state.ams_extruder_map = {"0": 0}
  2400. mock_state.raw_data = {"ams": {"ams": []}}
  2401. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2402. mock_pm.get_client.return_value = mock_client
  2403. mock_pm.get_status.return_value = mock_state
  2404. # First call with cali_idx=5
  2405. await async_client.post(
  2406. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2407. params={
  2408. "tray_info_idx": "GFL05",
  2409. "tray_type": "PLA",
  2410. "tray_sub_brands": "PLA Basic",
  2411. "tray_color": "FFFFFFFF",
  2412. "nozzle_temp_min": 190,
  2413. "nozzle_temp_max": 230,
  2414. "cali_idx": 5,
  2415. "k_value": 0.020,
  2416. },
  2417. )
  2418. # Second call with cali_idx=10 (same slot/spool/extruder/nozzle)
  2419. await async_client.post(
  2420. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2421. params={
  2422. "tray_info_idx": "GFL05",
  2423. "tray_type": "PLA",
  2424. "tray_sub_brands": "PLA Matte",
  2425. "tray_color": "FFFFFFFF",
  2426. "nozzle_temp_min": 190,
  2427. "nozzle_temp_max": 230,
  2428. "cali_idx": 10,
  2429. "k_value": 0.025,
  2430. },
  2431. )
  2432. # Should be exactly ONE row (updated), not two
  2433. kps = (
  2434. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  2435. .scalars()
  2436. .all()
  2437. )
  2438. assert len(kps) == 1
  2439. assert kps[0].cali_idx == 10 # updated to most recent
  2440. assert kps[0].name == "PLA Matte"
  2441. @pytest.mark.asyncio
  2442. @pytest.mark.integration
  2443. async def test_external_slot_extruder_inversion(
  2444. self,
  2445. async_client: AsyncClient,
  2446. db_session,
  2447. printer_factory,
  2448. ):
  2449. """ams_id=255 + tray_id=0 → kp.extruder=1 (ext-L); tray_id=1 → extruder=0 (ext-R)."""
  2450. from backend.app.models.spool import Spool
  2451. from backend.app.models.spool_assignment import SpoolAssignment
  2452. from backend.app.models.spool_k_profile import SpoolKProfile
  2453. printer = await printer_factory(model="H2D")
  2454. spool = Spool(material="PLA")
  2455. db_session.add(spool)
  2456. await db_session.flush()
  2457. # Note: SpoolmanSlotAssignment can't store ams_id=255 with tray_id=1
  2458. # under the ck_tray_id_range constraint (0-3 valid). External-slot
  2459. # K-profile persistence is therefore tested via local SpoolAssignment.
  2460. db_session.add(
  2461. SpoolAssignment(
  2462. spool_id=spool.id,
  2463. printer_id=printer.id,
  2464. ams_id=255,
  2465. tray_id=0,
  2466. )
  2467. )
  2468. await db_session.commit()
  2469. spool_id = spool.id
  2470. mock_client = MagicMock()
  2471. mock_client.ams_set_filament_setting.return_value = True
  2472. mock_client.extrusion_cali_sel.return_value = True
  2473. mock_client.request_status_update.return_value = True
  2474. mock_state = MagicMock()
  2475. mock_state.ams_extruder_map = {"0": 0} # truthy so external-inversion path runs
  2476. mock_state.raw_data = {"ams": {"ams": []}}
  2477. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2478. mock_pm.get_client.return_value = mock_client
  2479. mock_pm.get_status.return_value = mock_state
  2480. response = await async_client.post(
  2481. f"/api/v1/printers/{printer.id}/slots/255/0/configure",
  2482. params={
  2483. "tray_info_idx": "GFL05",
  2484. "tray_type": "PLA",
  2485. "tray_sub_brands": "PLA Basic",
  2486. "tray_color": "FFFFFFFF",
  2487. "nozzle_temp_min": 190,
  2488. "nozzle_temp_max": 230,
  2489. "cali_idx": 5,
  2490. "k_value": 0.020,
  2491. },
  2492. )
  2493. assert response.status_code == 200
  2494. kp = (
  2495. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2496. ).scalar_one_or_none()
  2497. assert kp is not None
  2498. # tray_id=0 → extruder = 1 - 0 = 1
  2499. assert kp.extruder == 1
  2500. @pytest.mark.asyncio
  2501. @pytest.mark.integration
  2502. async def test_dual_nozzle_extruder_persists(
  2503. self,
  2504. async_client: AsyncClient,
  2505. db_session,
  2506. printer_factory,
  2507. ):
  2508. """ams_extruder_map with extruder=1 → kp.extruder=1 persisted correctly."""
  2509. from backend.app.models.spool import Spool
  2510. from backend.app.models.spool_assignment import SpoolAssignment
  2511. from backend.app.models.spool_k_profile import SpoolKProfile
  2512. printer = await printer_factory(model="H2D")
  2513. spool = Spool(material="PLA")
  2514. db_session.add(spool)
  2515. await db_session.flush()
  2516. db_session.add(
  2517. SpoolAssignment(
  2518. spool_id=spool.id,
  2519. printer_id=printer.id,
  2520. ams_id=2,
  2521. tray_id=3,
  2522. )
  2523. )
  2524. await db_session.commit()
  2525. spool_id = spool.id
  2526. mock_client = MagicMock()
  2527. mock_client.ams_set_filament_setting.return_value = True
  2528. mock_client.extrusion_cali_sel.return_value = True
  2529. mock_client.request_status_update.return_value = True
  2530. mock_state = MagicMock()
  2531. mock_state.ams_extruder_map = {"2": 1} # AMS 2 is on extruder 1
  2532. mock_state.raw_data = {"ams": {"ams": []}}
  2533. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2534. mock_pm.get_client.return_value = mock_client
  2535. mock_pm.get_status.return_value = mock_state
  2536. response = await async_client.post(
  2537. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  2538. params={
  2539. "tray_info_idx": "GFL05",
  2540. "tray_type": "PLA",
  2541. "tray_sub_brands": "PLA Basic",
  2542. "tray_color": "FFFFFFFF",
  2543. "nozzle_temp_min": 190,
  2544. "nozzle_temp_max": 230,
  2545. "cali_idx": 5,
  2546. "k_value": 0.020,
  2547. },
  2548. )
  2549. assert response.status_code == 200
  2550. kp = (
  2551. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2552. ).scalar_one_or_none()
  2553. assert kp is not None
  2554. assert kp.extruder == 1
  2555. @pytest.mark.asyncio
  2556. @pytest.mark.integration
  2557. async def test_db_error_does_not_fail_endpoint(
  2558. self,
  2559. async_client: AsyncClient,
  2560. db_session,
  2561. printer_factory,
  2562. ):
  2563. """DB errors during K-profile persistence are best-effort — endpoint still returns 200.
  2564. Verifies the try/except wrap added in P13-3b: if DB upsert fails (e.g.
  2565. because the schema is out of sync, a constraint violation, or any
  2566. other transient error), the MQTT command was already sent successfully
  2567. so we shouldn't return 500 to the user. The error is logged and the
  2568. endpoint returns success.
  2569. """
  2570. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2571. printer = await printer_factory(model="H2D")
  2572. db_session.add(
  2573. SpoolmanSlotAssignment(
  2574. printer_id=printer.id,
  2575. ams_id=0,
  2576. tray_id=3,
  2577. spoolman_spool_id=216,
  2578. )
  2579. )
  2580. await db_session.commit()
  2581. mock_client = MagicMock()
  2582. mock_client.ams_set_filament_setting.return_value = True
  2583. mock_client.extrusion_cali_sel.return_value = True
  2584. mock_client.request_status_update.return_value = True
  2585. mock_state = MagicMock()
  2586. mock_state.ams_extruder_map = {"0": 0}
  2587. mock_state.raw_data = {"ams": {"ams": []}}
  2588. # Force the K-profile persistence path to fail by patching the
  2589. # SpoolmanKProfile model class with a sentinel that raises when
  2590. # instantiated. The MQTT call has already happened by then, so the
  2591. # endpoint must catch and log without returning 500.
  2592. with (
  2593. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2594. patch(
  2595. "backend.app.models.spoolman_k_profile.SpoolmanKProfile",
  2596. side_effect=RuntimeError("Simulated DB error"),
  2597. ),
  2598. ):
  2599. mock_pm.get_client.return_value = mock_client
  2600. mock_pm.get_status.return_value = mock_state
  2601. response = await async_client.post(
  2602. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2603. params={
  2604. "tray_info_idx": "GFL05",
  2605. "tray_type": "PLA",
  2606. "tray_sub_brands": "PLA Basic",
  2607. "tray_color": "FFFFFFFF",
  2608. "nozzle_temp_min": 190,
  2609. "nozzle_temp_max": 230,
  2610. "cali_idx": 5,
  2611. "k_value": 0.020,
  2612. },
  2613. )
  2614. # Endpoint returns success — MQTT was sent, K-profile failed silently
  2615. assert response.status_code == 200
  2616. # MQTT was indeed called
  2617. mock_client.extrusion_cali_sel.assert_called_once()
  2618. class TestPrinterAccessCodeVisibility:
  2619. """Regression coverage: GET /printers and GET /printers/{id} must NOT
  2620. return ``access_code`` to callers without PRINTERS_UPDATE authority.
  2621. Holding ``access_code`` lets the caller talk to the printer's MQTT
  2622. directly with serial+code, bypassing every PRINTERS_CONTROL /
  2623. PRINTERS_FILES / PRINTERS_AMS_RFID check Bambuddy enforces.
  2624. Trust matrix encoded here:
  2625. - Auth disabled → access_code visible (single-trust mode)
  2626. - JWT Admin → access_code visible
  2627. - JWT Operator (has *_UPDATE) → access_code visible (VP-card UX)
  2628. - JWT Viewer → access_code STRIPPED
  2629. - API key with can_read_status → access_code STRIPPED
  2630. """
  2631. @pytest.fixture
  2632. async def auth_setup(self, async_client: AsyncClient):
  2633. await async_client.post(
  2634. "/api/v1/auth/setup",
  2635. json={
  2636. "auth_enabled": True,
  2637. "admin_username": "pcadmin",
  2638. "admin_password": "AdminPass1!",
  2639. },
  2640. )
  2641. async def _login(username, password):
  2642. resp = await async_client.post(
  2643. "/api/v1/auth/login",
  2644. json={"username": username, "password": password},
  2645. )
  2646. return resp.json()["access_token"]
  2647. admin_token = await _login("pcadmin", "AdminPass1!")
  2648. groups = (
  2649. await async_client.get(
  2650. "/api/v1/groups/",
  2651. headers={"Authorization": f"Bearer {admin_token}"},
  2652. )
  2653. ).json()
  2654. operators_group = next(g for g in groups if g["name"] == "Operators")
  2655. viewers_group = next(g for g in groups if g["name"] == "Viewers")
  2656. for username, password, group in (
  2657. ("pcoperator", "Operpass1!", operators_group["id"]),
  2658. ("pcviewer", "Viewpass1!", viewers_group["id"]),
  2659. ):
  2660. await async_client.post(
  2661. "/api/v1/users/",
  2662. headers={"Authorization": f"Bearer {admin_token}"},
  2663. json={"username": username, "password": password, "group_ids": [group]},
  2664. )
  2665. operator_token = await _login("pcoperator", "Operpass1!")
  2666. viewer_token = await _login("pcviewer", "Viewpass1!")
  2667. return {
  2668. "admin_token": admin_token,
  2669. "operator_token": operator_token,
  2670. "viewer_token": viewer_token,
  2671. }
  2672. async def _seed_printer_with_known_code(self, async_client: AsyncClient, admin_token: str) -> int:
  2673. resp = await async_client.post(
  2674. "/api/v1/printers/",
  2675. headers={"Authorization": f"Bearer {admin_token}"},
  2676. json={
  2677. "name": "AC-Visibility",
  2678. "serial_number": "00M09AVISIBILITY",
  2679. "ip_address": "192.168.42.42",
  2680. "access_code": "SECRET-CODE",
  2681. "is_active": True,
  2682. "model": "X1C",
  2683. },
  2684. )
  2685. assert resp.status_code == 200, resp.text
  2686. return resp.json()["id"]
  2687. @pytest.mark.asyncio
  2688. @pytest.mark.integration
  2689. async def test_auth_disabled_includes_access_code(self, async_client: AsyncClient, printer_factory):
  2690. """Single-trust mode: behaviour preserved, code is visible."""
  2691. printer = await printer_factory(name="AuthOff", access_code="LOCAL-CODE")
  2692. list_resp = await async_client.get("/api/v1/printers/")
  2693. detail_resp = await async_client.get(f"/api/v1/printers/{printer.id}")
  2694. assert list_resp.status_code == 200
  2695. assert detail_resp.status_code == 200
  2696. match = next(p for p in list_resp.json() if p["id"] == printer.id)
  2697. assert match["access_code"] == "LOCAL-CODE"
  2698. assert detail_resp.json()["access_code"] == "LOCAL-CODE"
  2699. @pytest.mark.asyncio
  2700. @pytest.mark.integration
  2701. async def test_admin_jwt_includes_access_code(self, async_client: AsyncClient, auth_setup):
  2702. printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
  2703. headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
  2704. list_resp = await async_client.get("/api/v1/printers/", headers=headers)
  2705. detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
  2706. assert list_resp.status_code == 200
  2707. assert detail_resp.status_code == 200
  2708. match = next(p for p in list_resp.json() if p["id"] == printer_id)
  2709. assert match["access_code"] == "SECRET-CODE"
  2710. assert detail_resp.json()["access_code"] == "SECRET-CODE"
  2711. @pytest.mark.asyncio
  2712. @pytest.mark.integration
  2713. async def test_operator_jwt_includes_access_code(self, async_client: AsyncClient, auth_setup):
  2714. """Operators hold PRINTERS_UPDATE (default role) — the VP-card UX
  2715. surfaces the target printer's access_code so they can configure
  2716. their slicer. The visibility predicate must keep working for them.
  2717. """
  2718. printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
  2719. headers = {"Authorization": f"Bearer {auth_setup['operator_token']}"}
  2720. list_resp = await async_client.get("/api/v1/printers/", headers=headers)
  2721. detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
  2722. assert list_resp.status_code == 200
  2723. assert detail_resp.status_code == 200
  2724. match = next(p for p in list_resp.json() if p["id"] == printer_id)
  2725. assert match["access_code"] == "SECRET-CODE"
  2726. assert detail_resp.json()["access_code"] == "SECRET-CODE"
  2727. @pytest.mark.asyncio
  2728. @pytest.mark.integration
  2729. async def test_viewer_jwt_excludes_access_code(self, async_client: AsyncClient, auth_setup):
  2730. """The fix: Viewers hold PRINTERS_READ but not PRINTERS_UPDATE, and
  2731. must NOT be able to read the printer's secret.
  2732. """
  2733. printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
  2734. headers = {"Authorization": f"Bearer {auth_setup['viewer_token']}"}
  2735. list_resp = await async_client.get("/api/v1/printers/", headers=headers)
  2736. detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
  2737. assert list_resp.status_code == 200
  2738. assert detail_resp.status_code == 200
  2739. match = next(p for p in list_resp.json() if p["id"] == printer_id)
  2740. # Field absent OR null — both are acceptable (no usable secret reaches the wire).
  2741. assert "access_code" not in match or match["access_code"] is None
  2742. body = detail_resp.json()
  2743. assert "access_code" not in body or body["access_code"] is None
  2744. # And the rest of the payload still arrives so the UI keeps working.
  2745. assert match["name"] == "AC-Visibility"
  2746. assert body["name"] == "AC-Visibility"
  2747. @pytest.mark.asyncio
  2748. @pytest.mark.integration
  2749. async def test_api_key_excludes_access_code(self, async_client: AsyncClient, auth_setup, db_session):
  2750. """API keys with can_read_status hold PRINTERS_READ but the predicate
  2751. gates on PRINTERS_UPDATE (admin-only / API-key-unmapped). The key
  2752. must NOT be able to exfiltrate access_code.
  2753. """
  2754. from backend.app.core.auth import generate_api_key
  2755. from backend.app.models.api_key import APIKey
  2756. printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
  2757. full_key, key_hash, key_prefix = generate_api_key()
  2758. api_key = APIKey(
  2759. name="visibility-key",
  2760. key_hash=key_hash,
  2761. key_prefix=key_prefix,
  2762. can_read_status=True,
  2763. enabled=True,
  2764. )
  2765. db_session.add(api_key)
  2766. await db_session.commit()
  2767. list_resp = await async_client.get("/api/v1/printers/", headers={"X-API-Key": full_key})
  2768. detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers={"X-API-Key": full_key})
  2769. assert list_resp.status_code == 200
  2770. assert detail_resp.status_code == 200
  2771. match = next(p for p in list_resp.json() if p["id"] == printer_id)
  2772. assert "access_code" not in match or match["access_code"] is None
  2773. body = detail_resp.json()
  2774. assert "access_code" not in body or body["access_code"] is None
  2775. class TestSetNozzleTemperatureAPI:
  2776. """Integration tests for POST /printers/{id}/temperature/nozzle (#1661)."""
  2777. @pytest.mark.asyncio
  2778. @pytest.mark.integration
  2779. async def test_not_found(self, async_client: AsyncClient):
  2780. response = await async_client.post("/api/v1/printers/99999/temperature/nozzle?target=220")
  2781. assert response.status_code == 404
  2782. @pytest.mark.asyncio
  2783. @pytest.mark.integration
  2784. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  2785. printer = await printer_factory(name="P", model="X1C")
  2786. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2787. mock_pm.get_client.return_value = None
  2788. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220")
  2789. assert response.status_code == 400
  2790. @pytest.mark.asyncio
  2791. @pytest.mark.integration
  2792. async def test_default_nozzle_index(self, async_client: AsyncClient, printer_factory):
  2793. """Omitting nozzle defaults to 0 (right/default)."""
  2794. printer = await printer_factory(name="P", model="X1C")
  2795. mock_client = MagicMock()
  2796. mock_client.set_nozzle_temperature.return_value = True
  2797. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2798. mock_pm.get_client.return_value = mock_client
  2799. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220")
  2800. assert response.status_code == 200
  2801. mock_client.set_nozzle_temperature.assert_called_once_with(220, 0)
  2802. @pytest.mark.asyncio
  2803. @pytest.mark.integration
  2804. async def test_dual_nozzle_left(self, async_client: AsyncClient, printer_factory):
  2805. """nozzle=1 reaches the client method as the second positional arg."""
  2806. printer = await printer_factory(name="P", model="H2D")
  2807. mock_client = MagicMock()
  2808. mock_client.set_nozzle_temperature.return_value = True
  2809. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2810. mock_pm.get_client.return_value = mock_client
  2811. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=260&nozzle=1")
  2812. assert response.status_code == 200
  2813. mock_client.set_nozzle_temperature.assert_called_once_with(260, 1)
  2814. @pytest.mark.asyncio
  2815. @pytest.mark.integration
  2816. async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
  2817. """target=0 turns the heater off; must NOT be rejected by Query bounds."""
  2818. printer = await printer_factory(name="P", model="X1C")
  2819. mock_client = MagicMock()
  2820. mock_client.set_nozzle_temperature.return_value = True
  2821. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2822. mock_pm.get_client.return_value = mock_client
  2823. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=0")
  2824. assert response.status_code == 200
  2825. mock_client.set_nozzle_temperature.assert_called_once_with(0, 0)
  2826. @pytest.mark.asyncio
  2827. @pytest.mark.integration
  2828. async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  2829. printer = await printer_factory(name="P", model="X1C")
  2830. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=400")
  2831. assert response.status_code == 422 # FastAPI bounds violation
  2832. @pytest.mark.asyncio
  2833. @pytest.mark.integration
  2834. async def test_nozzle_index_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  2835. printer = await printer_factory(name="P", model="H2D")
  2836. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220&nozzle=2")
  2837. assert response.status_code == 422
  2838. @pytest.mark.asyncio
  2839. @pytest.mark.integration
  2840. async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  2841. printer = await printer_factory(name="P", model="X1C")
  2842. mock_client = MagicMock()
  2843. mock_client.set_nozzle_temperature.return_value = False
  2844. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2845. mock_pm.get_client.return_value = mock_client
  2846. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220")
  2847. assert response.status_code == 500
  2848. class TestSetBedTemperatureAPI:
  2849. """Integration tests for POST /printers/{id}/temperature/bed (#1661)."""
  2850. @pytest.mark.asyncio
  2851. @pytest.mark.integration
  2852. async def test_not_found(self, async_client: AsyncClient):
  2853. response = await async_client.post("/api/v1/printers/99999/temperature/bed?target=60")
  2854. assert response.status_code == 404
  2855. @pytest.mark.asyncio
  2856. @pytest.mark.integration
  2857. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  2858. printer = await printer_factory(name="P", model="X1C")
  2859. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2860. mock_pm.get_client.return_value = None
  2861. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=60")
  2862. assert response.status_code == 400
  2863. @pytest.mark.asyncio
  2864. @pytest.mark.integration
  2865. async def test_success(self, async_client: AsyncClient, printer_factory):
  2866. printer = await printer_factory(name="P", model="X1C")
  2867. mock_client = MagicMock()
  2868. mock_client.set_bed_temperature.return_value = True
  2869. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2870. mock_pm.get_client.return_value = mock_client
  2871. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=60")
  2872. assert response.status_code == 200
  2873. mock_client.set_bed_temperature.assert_called_once_with(60)
  2874. @pytest.mark.asyncio
  2875. @pytest.mark.integration
  2876. async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
  2877. """target=0 turns the bed heater off."""
  2878. printer = await printer_factory(name="P", model="X1C")
  2879. mock_client = MagicMock()
  2880. mock_client.set_bed_temperature.return_value = True
  2881. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2882. mock_pm.get_client.return_value = mock_client
  2883. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=0")
  2884. assert response.status_code == 200
  2885. mock_client.set_bed_temperature.assert_called_once_with(0)
  2886. @pytest.mark.asyncio
  2887. @pytest.mark.integration
  2888. async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  2889. printer = await printer_factory(name="P", model="X1C")
  2890. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=200")
  2891. assert response.status_code == 422
  2892. @pytest.mark.asyncio
  2893. @pytest.mark.integration
  2894. async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  2895. printer = await printer_factory(name="P", model="X1C")
  2896. mock_client = MagicMock()
  2897. mock_client.set_bed_temperature.return_value = False
  2898. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2899. mock_pm.get_client.return_value = mock_client
  2900. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=60")
  2901. assert response.status_code == 500
  2902. class TestSetChamberTemperatureAPI:
  2903. """Integration tests for POST /printers/{id}/temperature/chamber.
  2904. Gated on supports_chamber_heater(model). Sensor-only models that report
  2905. chamber temp but have no heater (X1C, X1E, P2S) get a 400 at the route
  2906. level rather than a silent no-op at the firmware level.
  2907. """
  2908. @pytest.mark.asyncio
  2909. @pytest.mark.integration
  2910. async def test_not_found(self, async_client: AsyncClient):
  2911. response = await async_client.post("/api/v1/printers/99999/temperature/chamber?target=45")
  2912. assert response.status_code == 404
  2913. @pytest.mark.asyncio
  2914. @pytest.mark.integration
  2915. @pytest.mark.parametrize("sensor_only_model", ["X1C", "X1E", "P2S"])
  2916. async def test_sensor_only_model_rejected(self, async_client: AsyncClient, printer_factory, sensor_only_model):
  2917. """Models with sensor but no heater must 400 before any client call."""
  2918. printer = await printer_factory(name="P", model=sensor_only_model)
  2919. mock_client = MagicMock()
  2920. mock_client.set_chamber_temperature.return_value = True
  2921. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2922. mock_pm.get_client.return_value = mock_client
  2923. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
  2924. assert response.status_code == 400
  2925. # Client must NOT be called for sensor-only models.
  2926. mock_client.set_chamber_temperature.assert_not_called()
  2927. @pytest.mark.asyncio
  2928. @pytest.mark.integration
  2929. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  2930. printer = await printer_factory(name="P", model="H2D")
  2931. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2932. mock_pm.get_client.return_value = None
  2933. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
  2934. assert response.status_code == 400
  2935. @pytest.mark.asyncio
  2936. @pytest.mark.integration
  2937. @pytest.mark.parametrize("heater_model", ["H2C", "H2D", "H2DPRO", "H2S", "X2D"])
  2938. async def test_success_per_heater_model(self, async_client: AsyncClient, printer_factory, heater_model):
  2939. """All five heater-equipped models accept the command."""
  2940. printer = await printer_factory(name="P", model=heater_model)
  2941. mock_client = MagicMock()
  2942. mock_client.set_chamber_temperature.return_value = True
  2943. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2944. mock_pm.get_client.return_value = mock_client
  2945. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
  2946. assert response.status_code == 200
  2947. mock_client.set_chamber_temperature.assert_called_once_with(45)
  2948. @pytest.mark.asyncio
  2949. @pytest.mark.integration
  2950. async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
  2951. """target=0 turns the chamber heater off."""
  2952. printer = await printer_factory(name="P", model="H2D")
  2953. mock_client = MagicMock()
  2954. mock_client.set_chamber_temperature.return_value = True
  2955. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2956. mock_pm.get_client.return_value = mock_client
  2957. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=0")
  2958. assert response.status_code == 200
  2959. mock_client.set_chamber_temperature.assert_called_once_with(0)
  2960. @pytest.mark.asyncio
  2961. @pytest.mark.integration
  2962. async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  2963. printer = await printer_factory(name="P", model="H2D")
  2964. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=100")
  2965. assert response.status_code == 422
  2966. @pytest.mark.asyncio
  2967. @pytest.mark.integration
  2968. async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  2969. printer = await printer_factory(name="P", model="H2D")
  2970. mock_client = MagicMock()
  2971. mock_client.set_chamber_temperature.return_value = False
  2972. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2973. mock_pm.get_client.return_value = mock_client
  2974. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
  2975. assert response.status_code == 500
  2976. class TestSetFanSpeedAPI:
  2977. """Integration tests for POST /printers/{id}/fan-speed (#1661).
  2978. The fan-id mapping (part->1, aux->2, chamber->3) is the critical
  2979. correctness gate — wrong mapping would target the wrong physical fan.
  2980. """
  2981. @pytest.mark.asyncio
  2982. @pytest.mark.integration
  2983. async def test_invalid_fan_name_rejected(self, async_client: AsyncClient, printer_factory):
  2984. printer = await printer_factory(name="P", model="X1C")
  2985. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=foo&speed=50")
  2986. assert response.status_code == 400
  2987. @pytest.mark.asyncio
  2988. @pytest.mark.integration
  2989. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  2990. printer = await printer_factory(name="P", model="X1C")
  2991. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2992. mock_pm.get_client.return_value = None
  2993. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed=50")
  2994. assert response.status_code == 400
  2995. @pytest.mark.asyncio
  2996. @pytest.mark.integration
  2997. @pytest.mark.parametrize(
  2998. "fan_name,expected_fan_id",
  2999. [("part", 1), ("aux", 2), ("chamber", 3)],
  3000. )
  3001. async def test_fan_id_mapping(self, async_client: AsyncClient, printer_factory, fan_name, expected_fan_id):
  3002. """Verify each fan name maps to the correct hardware fan-id."""
  3003. printer = await printer_factory(name="P", model="X1C")
  3004. mock_client = MagicMock()
  3005. mock_client.set_fan_speed.return_value = True
  3006. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3007. mock_pm.get_client.return_value = mock_client
  3008. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=100")
  3009. assert response.status_code == 200
  3010. called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
  3011. assert called_fan_id == expected_fan_id
  3012. @pytest.mark.asyncio
  3013. @pytest.mark.integration
  3014. @pytest.mark.parametrize(
  3015. "speed_pct,expected_pwm",
  3016. [(0, 0), (50, 128), (100, 255)],
  3017. )
  3018. async def test_pwm_conversion(self, async_client: AsyncClient, printer_factory, speed_pct, expected_pwm):
  3019. """0-100% must convert to 0-255 PWM (round-to-nearest)."""
  3020. printer = await printer_factory(name="P", model="X1C")
  3021. mock_client = MagicMock()
  3022. mock_client.set_fan_speed.return_value = True
  3023. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3024. mock_pm.get_client.return_value = mock_client
  3025. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed={speed_pct}")
  3026. assert response.status_code == 200
  3027. _called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
  3028. assert called_pwm == expected_pwm
  3029. @pytest.mark.asyncio
  3030. @pytest.mark.integration
  3031. async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  3032. printer = await printer_factory(name="P", model="X1C")
  3033. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed=150")
  3034. assert response.status_code == 422
  3035. class TestSelectExtruderAPI:
  3036. """Integration tests for POST /printers/{id}/select-extruder (#1661)."""
  3037. @pytest.mark.asyncio
  3038. @pytest.mark.integration
  3039. async def test_not_found(self, async_client: AsyncClient):
  3040. response = await async_client.post("/api/v1/printers/99999/select-extruder?extruder=0")
  3041. assert response.status_code == 404
  3042. @pytest.mark.asyncio
  3043. @pytest.mark.integration
  3044. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3045. printer = await printer_factory(name="P", model="H2D")
  3046. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3047. mock_pm.get_client.return_value = None
  3048. response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder=0")
  3049. assert response.status_code == 400
  3050. @pytest.mark.asyncio
  3051. @pytest.mark.integration
  3052. @pytest.mark.parametrize("extruder", [0, 1])
  3053. async def test_select_each_extruder(self, async_client: AsyncClient, printer_factory, extruder):
  3054. printer = await printer_factory(name="P", model="H2D")
  3055. mock_client = MagicMock()
  3056. mock_client.select_extruder.return_value = True
  3057. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3058. mock_pm.get_client.return_value = mock_client
  3059. response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder={extruder}")
  3060. assert response.status_code == 200
  3061. mock_client.select_extruder.assert_called_once_with(extruder)
  3062. @pytest.mark.asyncio
  3063. @pytest.mark.integration
  3064. async def test_extruder_index_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  3065. printer = await printer_factory(name="P", model="H2D")
  3066. response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder=2")
  3067. assert response.status_code == 422
  3068. class TestXYJogAPI:
  3069. """Integration tests for POST /printers/{id}/xy-jog (#1661)."""
  3070. @pytest.mark.asyncio
  3071. @pytest.mark.integration
  3072. async def test_zero_movement_rejected(self, async_client: AsyncClient, printer_factory):
  3073. """x=0 AND y=0 (or omitted) must be rejected — no-op jog is a UI bug."""
  3074. printer = await printer_factory(name="P", model="X1C")
  3075. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog")
  3076. assert response.status_code == 400
  3077. @pytest.mark.asyncio
  3078. @pytest.mark.integration
  3079. @pytest.mark.parametrize("x,y", [(201, 0), (0, 201), (-300, 0), (0, -250)])
  3080. async def test_oversize_movement_rejected(self, async_client: AsyncClient, printer_factory, x, y):
  3081. """Per-axis bound is 200mm; over-bound must be rejected."""
  3082. printer = await printer_factory(name="P", model="X1C")
  3083. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x={x}&y={y}")
  3084. assert response.status_code == 400
  3085. @pytest.mark.asyncio
  3086. @pytest.mark.integration
  3087. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3088. printer = await printer_factory(name="P", model="X1C")
  3089. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3090. mock_pm.get_client.return_value = None
  3091. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=10&y=0")
  3092. assert response.status_code == 400
  3093. @pytest.mark.asyncio
  3094. @pytest.mark.integration
  3095. async def test_success_x_only_emits_relative_gcode(self, async_client: AsyncClient, printer_factory):
  3096. """X-only jog should emit G91/G90 wrapping and only include the X axis."""
  3097. printer = await printer_factory(name="P", model="X1C")
  3098. mock_client = MagicMock()
  3099. mock_client.send_gcode.return_value = True
  3100. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3101. mock_pm.get_client.return_value = mock_client
  3102. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=10&y=0")
  3103. assert response.status_code == 200
  3104. sent = mock_client.send_gcode.call_args.args[0]
  3105. assert sent.startswith("G91\n")
  3106. assert sent.endswith("\nG90")
  3107. assert "X10.00" in sent
  3108. assert "Y" not in sent # y=0 must NOT be included
  3109. @pytest.mark.asyncio
  3110. @pytest.mark.integration
  3111. async def test_success_both_axes(self, async_client: AsyncClient, printer_factory):
  3112. printer = await printer_factory(name="P", model="X1C")
  3113. mock_client = MagicMock()
  3114. mock_client.send_gcode.return_value = True
  3115. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3116. mock_pm.get_client.return_value = mock_client
  3117. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=-5&y=7")
  3118. assert response.status_code == 200
  3119. sent = mock_client.send_gcode.call_args.args[0]
  3120. assert "X-5.00" in sent and "Y7.00" in sent
  3121. class TestExtruderJogAPI:
  3122. """Integration tests for POST /printers/{id}/extruder-jog (#1661).
  3123. Note: Bambu firmware enforces the cold-extrude guard
  3124. (min-temp refusal) so the route deliberately does not gate on temp.
  3125. """
  3126. @pytest.mark.asyncio
  3127. @pytest.mark.integration
  3128. async def test_zero_distance_rejected(self, async_client: AsyncClient, printer_factory):
  3129. printer = await printer_factory(name="P", model="X1C")
  3130. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=0")
  3131. assert response.status_code == 400
  3132. @pytest.mark.asyncio
  3133. @pytest.mark.integration
  3134. @pytest.mark.parametrize("distance", [101, -200])
  3135. async def test_oversize_distance_rejected(self, async_client: AsyncClient, printer_factory, distance):
  3136. """Per-axis bound is 100mm; over-bound must be rejected."""
  3137. printer = await printer_factory(name="P", model="X1C")
  3138. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance={distance}")
  3139. assert response.status_code == 400
  3140. @pytest.mark.asyncio
  3141. @pytest.mark.integration
  3142. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3143. printer = await printer_factory(name="P", model="X1C")
  3144. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3145. mock_pm.get_client.return_value = None
  3146. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=5")
  3147. assert response.status_code == 400
  3148. @pytest.mark.asyncio
  3149. @pytest.mark.integration
  3150. async def test_extrude_uses_relative_e_mode(self, async_client: AsyncClient, printer_factory):
  3151. """Extruder jog must wrap with M83 (relative E) and restore M82 (absolute E)."""
  3152. printer = await printer_factory(name="P", model="X1C")
  3153. mock_client = MagicMock()
  3154. mock_client.send_gcode.return_value = True
  3155. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3156. mock_pm.get_client.return_value = mock_client
  3157. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=5")
  3158. assert response.status_code == 200
  3159. sent = mock_client.send_gcode.call_args.args[0]
  3160. assert sent.startswith("M83\n")
  3161. assert sent.endswith("\nM82")
  3162. assert "E5.00" in sent
  3163. @pytest.mark.asyncio
  3164. @pytest.mark.integration
  3165. async def test_retract_uses_negative_distance(self, async_client: AsyncClient, printer_factory):
  3166. printer = await printer_factory(name="P", model="X1C")
  3167. mock_client = MagicMock()
  3168. mock_client.send_gcode.return_value = True
  3169. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3170. mock_pm.get_client.return_value = mock_client
  3171. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=-3.5")
  3172. assert response.status_code == 200
  3173. sent = mock_client.send_gcode.call_args.args[0]
  3174. assert "E-3.50" in sent