test_printers_api.py 227 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338
  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. from backend.app.core.config import settings
  10. from backend.app.services.printer_media import PrinterFilesJobStatus, PrinterFilesZipResult
  11. @pytest.fixture(autouse=True)
  12. def _mock_printer_test_connection():
  13. """Default mock: connection test returns success.
  14. POST /printers/ now refuses to persist a printer when the MQTT
  15. connection probe fails (would otherwise leave an empty card in the
  16. dashboard for a mistyped access code). Existing tests assume the
  17. save succeeds, so we mock the probe green by default; the failure
  18. branch is exercised by a dedicated test below.
  19. """
  20. with patch(
  21. "backend.app.services.printer_manager.printer_manager.test_connection",
  22. new=AsyncMock(return_value={"success": True, "state": "IDLE", "model": "X1C"}),
  23. ) as m:
  24. yield m
  25. class TestPrintersAPI:
  26. """Integration tests for /api/v1/printers/ endpoints."""
  27. # ========================================================================
  28. # List endpoints
  29. # ========================================================================
  30. @pytest.mark.asyncio
  31. @pytest.mark.integration
  32. async def test_list_printers_empty(self, async_client: AsyncClient):
  33. """Verify empty list is returned when no printers exist."""
  34. response = await async_client.get("/api/v1/printers/")
  35. assert response.status_code == 200
  36. assert response.json() == []
  37. @pytest.mark.asyncio
  38. @pytest.mark.integration
  39. async def test_list_printers_with_data(self, async_client: AsyncClient, printer_factory, db_session):
  40. """Verify list returns existing printers."""
  41. await printer_factory(name="Test Printer")
  42. response = await async_client.get("/api/v1/printers/")
  43. assert response.status_code == 200
  44. data = response.json()
  45. assert len(data) >= 1
  46. assert any(p["name"] == "Test Printer" for p in data)
  47. # ========================================================================
  48. # Create endpoints
  49. # ========================================================================
  50. @pytest.mark.asyncio
  51. @pytest.mark.integration
  52. async def test_create_printer(self, async_client: AsyncClient):
  53. """Verify printer can be created."""
  54. data = {
  55. "name": "New Printer",
  56. "serial_number": "00M09A111111111",
  57. "ip_address": "192.168.1.100",
  58. "access_code": "12345678",
  59. "is_active": True,
  60. "model": "X1C",
  61. }
  62. response = await async_client.post("/api/v1/printers/", json=data)
  63. assert response.status_code == 200
  64. result = response.json()
  65. assert result["name"] == "New Printer"
  66. assert result["serial_number"] == "00M09A111111111"
  67. assert result["model"] == "X1C"
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_create_printer_with_hostname(self, async_client: AsyncClient):
  71. """Verify printer can be created with a hostname instead of IP address."""
  72. data = {
  73. "name": "DNS Printer",
  74. "serial_number": "00M09A555555555",
  75. "ip_address": "printer.local",
  76. "access_code": "12345678",
  77. "model": "P1S",
  78. }
  79. response = await async_client.post("/api/v1/printers/", json=data)
  80. assert response.status_code == 200
  81. result = response.json()
  82. assert result["name"] == "DNS Printer"
  83. assert result["ip_address"] == "printer.local"
  84. @pytest.mark.asyncio
  85. @pytest.mark.integration
  86. async def test_create_printer_with_fqdn(self, async_client: AsyncClient):
  87. """Verify printer can be created with a fully qualified domain name."""
  88. data = {
  89. "name": "FQDN Printer",
  90. "serial_number": "00M09A666666666",
  91. "ip_address": "my-printer.home.lan",
  92. "access_code": "12345678",
  93. "model": "X1C",
  94. }
  95. response = await async_client.post("/api/v1/printers/", json=data)
  96. assert response.status_code == 200
  97. result = response.json()
  98. assert result["ip_address"] == "my-printer.home.lan"
  99. @pytest.mark.asyncio
  100. @pytest.mark.integration
  101. async def test_create_printer_invalid_hostname(self, async_client: AsyncClient):
  102. """Verify invalid hostnames are rejected."""
  103. data = {
  104. "name": "Bad Printer",
  105. "serial_number": "00M09A777777777",
  106. "ip_address": "-invalid",
  107. "access_code": "12345678",
  108. }
  109. response = await async_client.post("/api/v1/printers/", json=data)
  110. assert response.status_code == 422
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):
  114. """Verify duplicate serial number is rejected."""
  115. await printer_factory(serial_number="00M09A222222222")
  116. data = {
  117. "name": "Duplicate Printer",
  118. "serial_number": "00M09A222222222",
  119. "ip_address": "192.168.1.101",
  120. "access_code": "12345678",
  121. }
  122. response = await async_client.post("/api/v1/printers/", json=data)
  123. # Should fail due to duplicate serial
  124. assert response.status_code in [400, 409, 422, 500]
  125. @pytest.mark.asyncio
  126. @pytest.mark.integration
  127. async def test_create_printer_rejects_when_mqtt_probe_fails(self, async_client: AsyncClient, db_session):
  128. """Wrong access code / unreachable IP must NOT persist the printer.
  129. Regression: users were reporting empty / never-connecting printer
  130. cards that traced back to a mistyped access code. The create route
  131. now runs an MQTT probe up front and returns 400 if it fails — the
  132. row is never written.
  133. """
  134. data = {
  135. "name": "Bad Code Printer",
  136. "serial_number": "00M09A999999999",
  137. "ip_address": "192.168.1.250",
  138. "access_code": "WRONG-CODE",
  139. "is_active": True,
  140. "model": "X1C",
  141. }
  142. with patch(
  143. "backend.app.services.printer_manager.printer_manager.test_connection",
  144. new=AsyncMock(return_value={"success": False, "state": None, "model": None}),
  145. ):
  146. response = await async_client.post("/api/v1/printers/", json=data)
  147. assert response.status_code == 400
  148. detail = response.json()["detail"]
  149. # Backend returns a stable code for the frontend i18n layer to map;
  150. # the message field is an English fallback for non-UI clients.
  151. assert detail["code"] == "printer_connection_failed"
  152. assert "connect" in detail["message"].lower()
  153. # And critically: the printer row was never persisted.
  154. from backend.app.models.printer import Printer
  155. result = await db_session.execute(select(Printer).where(Printer.serial_number == "00M09A999999999"))
  156. assert result.scalar_one_or_none() is None, "Failed-probe printer must not be persisted"
  157. # ========================================================================
  158. # Get single endpoint
  159. # ========================================================================
  160. @pytest.mark.asyncio
  161. @pytest.mark.integration
  162. async def test_get_printer(self, async_client: AsyncClient, printer_factory, db_session):
  163. """Verify single printer can be retrieved."""
  164. printer = await printer_factory(name="Get Test Printer")
  165. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  166. assert response.status_code == 200
  167. result = response.json()
  168. assert result["id"] == printer.id
  169. assert result["name"] == "Get Test Printer"
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_get_printer_not_found(self, async_client: AsyncClient):
  173. """Verify 404 for non-existent printer."""
  174. response = await async_client.get("/api/v1/printers/9999")
  175. assert response.status_code == 404
  176. # ========================================================================
  177. # Update endpoints
  178. # ========================================================================
  179. @pytest.mark.asyncio
  180. @pytest.mark.integration
  181. async def test_update_printer_name(self, async_client: AsyncClient, printer_factory, db_session):
  182. """Verify printer name can be updated."""
  183. printer = await printer_factory(name="Original Name")
  184. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated Name"})
  185. assert response.status_code == 200
  186. assert response.json()["name"] == "Updated Name"
  187. @pytest.mark.asyncio
  188. @pytest.mark.integration
  189. async def test_update_printer_active_status(self, async_client: AsyncClient, printer_factory, db_session):
  190. """Verify printer active status can be updated."""
  191. printer = await printer_factory(is_active=True)
  192. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"is_active": False})
  193. assert response.status_code == 200
  194. assert response.json()["is_active"] is False
  195. @pytest.mark.asyncio
  196. @pytest.mark.integration
  197. async def test_update_printer_auto_archive(self, async_client: AsyncClient, printer_factory, db_session):
  198. """Verify auto_archive setting can be updated."""
  199. printer = await printer_factory(auto_archive=True)
  200. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"auto_archive": False})
  201. assert response.status_code == 200
  202. assert response.json()["auto_archive"] is False
  203. @pytest.mark.asyncio
  204. @pytest.mark.integration
  205. async def test_update_nonexistent_printer(self, async_client: AsyncClient):
  206. """Verify updating non-existent printer returns 404."""
  207. response = await async_client.patch("/api/v1/printers/9999", json={"name": "New Name"})
  208. assert response.status_code == 404
  209. # ========================================================================
  210. # Delete endpoints
  211. # ========================================================================
  212. @pytest.mark.asyncio
  213. @pytest.mark.integration
  214. async def test_delete_printer(self, async_client: AsyncClient, printer_factory, db_session):
  215. """Verify printer can be deleted."""
  216. printer = await printer_factory()
  217. printer_id = printer.id
  218. response = await async_client.delete(f"/api/v1/printers/{printer_id}")
  219. assert response.status_code == 200
  220. # Verify deleted
  221. response = await async_client.get(f"/api/v1/printers/{printer_id}")
  222. assert response.status_code == 404
  223. @pytest.mark.asyncio
  224. @pytest.mark.integration
  225. async def test_delete_printer_removes_scheduled_dryings(
  226. self, async_client: AsyncClient, printer_factory, db_session
  227. ):
  228. """Deleting a printer must also delete its scheduled_dryings rows, since
  229. SQLite doesn't enforce FK cascades (#2638).
  230. """
  231. from backend.app.models.scheduled_drying import ScheduledDrying
  232. printer = await printer_factory()
  233. printer_id = printer.id
  234. row = ScheduledDrying(printer_id=printer_id, ams_id=0, temp=55, duration_hours=8)
  235. db_session.add(row)
  236. await db_session.commit()
  237. row_id = row.id
  238. response = await async_client.delete(f"/api/v1/printers/{printer_id}")
  239. assert response.status_code == 200
  240. result = await db_session.execute(select(ScheduledDrying).where(ScheduledDrying.id == row_id))
  241. assert result.scalar_one_or_none() is None
  242. @pytest.mark.asyncio
  243. @pytest.mark.integration
  244. async def test_delete_nonexistent_printer(self, async_client: AsyncClient):
  245. """Verify deleting non-existent printer returns 404."""
  246. response = await async_client.delete("/api/v1/printers/9999")
  247. assert response.status_code == 404
  248. # ========================================================================
  249. # File download endpoint — non-ASCII filename regression (#1245)
  250. # ========================================================================
  251. @pytest.mark.asyncio
  252. @pytest.mark.integration
  253. @pytest.mark.parametrize(
  254. ("filename", "ascii_fallback"),
  255. [
  256. ("龙泡泡石墩子_p2s_ok.gcode.3mf", "p2s_ok.gcode.3mf"),
  257. ("こんにちは.gcode.3mf", "gcode.3mf"),
  258. ("résumé.gcode.3mf", "rsum.gcode.3mf"),
  259. ("مرحبا.gcode.3mf", "gcode.3mf"),
  260. ("文件.3mf", "3mf"),
  261. ("hello.3mf", "hello.3mf"),
  262. ],
  263. )
  264. async def test_download_printer_file_non_ascii_filename(
  265. self,
  266. async_client: AsyncClient,
  267. printer_factory,
  268. filename: str,
  269. ascii_fallback: str,
  270. db_session,
  271. tmp_path,
  272. ):
  273. """Non-ASCII filenames must not crash header encoding (issue #1245)."""
  274. printer = await printer_factory()
  275. file_bytes = b"fake 3mf content"
  276. staged = tmp_path / "single" / "printer-file"
  277. staged.parent.mkdir()
  278. staged.write_bytes(file_bytes)
  279. with patch(
  280. "backend.app.api.routes.printers.build_printer_file",
  281. new=AsyncMock(
  282. return_value=PrinterFilesZipResult(
  283. path=staged,
  284. requested=1,
  285. successful=1,
  286. failed_paths=(),
  287. total_bytes=len(file_bytes),
  288. )
  289. ),
  290. ):
  291. response = await async_client.get(
  292. f"/api/v1/printers/{printer.id}/files/download",
  293. params={"path": f"/cache/{filename}"},
  294. )
  295. assert response.status_code == 200
  296. assert response.content == file_bytes
  297. content_disposition = response.headers["content-disposition"]
  298. assert f'filename="{ascii_fallback}"' in content_disposition
  299. assert "filename*=UTF-8''" in content_disposition
  300. encoded_name = content_disposition.split("filename*=UTF-8''", 1)[1]
  301. assert unquote(encoded_name) == filename
  302. @pytest.mark.asyncio
  303. @pytest.mark.integration
  304. async def test_download_job_returns_immediately_and_prepared_token_streams_file_response(
  305. self,
  306. async_client: AsyncClient,
  307. printer_factory,
  308. tmp_path,
  309. monkeypatch,
  310. ):
  311. """The browser polls a short job request, then uses a native GET."""
  312. from backend.app.core.auth import create_slicer_download_token
  313. printer = await printer_factory()
  314. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  315. token = await create_slicer_download_token("printer-files", printer.id)
  316. bundle_dir = settings.archive_dir / "temp" / "printer-file-downloads" / f"{printer.id}-{token}"
  317. bundle_dir.mkdir(parents=True)
  318. zip_path = bundle_dir / "printer-files.zip"
  319. zip_path.write_bytes(b"disk-backed zip")
  320. with patch(
  321. "backend.app.api.routes.printers.start_printer_files_job",
  322. new=AsyncMock(
  323. return_value=PrinterFilesJobStatus(
  324. job_id="job-id",
  325. printer_id=printer.id,
  326. state="queued",
  327. requested=2,
  328. filename="Test Printer videos.zip",
  329. )
  330. ),
  331. ):
  332. job_response = await async_client.post(
  333. f"/api/v1/printers/{printer.id}/files/download-job",
  334. json={
  335. "paths": ["/ipcam/one.mp4", "/ipcam/two.mp4"],
  336. "sizes": {"/ipcam/one.mp4": 7, "/ipcam/two.mp4": 8},
  337. "filename": "Test Printer videos.zip",
  338. "as_zip": True,
  339. },
  340. )
  341. assert job_response.status_code == 200
  342. assert job_response.json() == {
  343. "job_id": "job-id",
  344. "printer_id": printer.id,
  345. "state": "queued",
  346. "requested": 2,
  347. "successful": 0,
  348. "failed": 0,
  349. "token": None,
  350. "filename": "Test Printer videos.zip",
  351. "message": None,
  352. }
  353. response = await async_client.get(
  354. f"/api/v1/printers/{printer.id}/files/dl/{token}/Test%20Printer%20videos.zip",
  355. )
  356. assert response.status_code == 200
  357. assert response.content == b"disk-backed zip"
  358. assert 'filename="Test Printer videos.zip"' in response.headers["content-disposition"]
  359. # Tokens are single-use, including after a successful large download.
  360. replay = await async_client.get(
  361. f"/api/v1/printers/{printer.id}/files/dl/{token}/Test%20Printer%20videos.zip",
  362. )
  363. assert replay.status_code == 403
  364. # The browser reaches this URL through an <a download> click and saves
  365. # whatever comes back under the name it was going to use, so a refusal
  366. # has to arrive as a legible file rather than as a JSON body landing on
  367. # the user's disk named .zip.
  368. assert replay.headers["content-type"].startswith("text/plain")
  369. assert 'filename="download-failed.txt"' in replay.headers["content-disposition"]
  370. assert b"expired" in replay.content
  371. @pytest.mark.asyncio
  372. @pytest.mark.integration
  373. async def test_bulk_download_token_rejects_another_printer_without_consuming_it(
  374. self,
  375. async_client: AsyncClient,
  376. printer_factory,
  377. tmp_path,
  378. monkeypatch,
  379. ):
  380. """A printer-A token cannot download through printer B and survives that attempt."""
  381. printer_a = await printer_factory(name="Printer A", serial_number="TOKENBINDINGA01")
  382. printer_b = await printer_factory(name="Printer B", serial_number="TOKENBINDINGB01")
  383. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  384. from backend.app.core.auth import create_slicer_download_token
  385. token = await create_slicer_download_token("printer-files", printer_a.id)
  386. bundle_dir = settings.archive_dir / "temp" / "printer-file-downloads" / f"{printer_a.id}-{token}"
  387. bundle_dir.mkdir(parents=True)
  388. zip_path = bundle_dir / "printer-files.zip"
  389. zip_path.write_bytes(b"bound zip")
  390. wrong_printer = await async_client.get(
  391. f"/api/v1/printers/{printer_b.id}/files/dl/{token}/printer-files.zip",
  392. )
  393. assert wrong_printer.status_code == 403
  394. correct_printer = await async_client.get(
  395. f"/api/v1/printers/{printer_a.id}/files/dl/{token}/printer-files.zip",
  396. )
  397. assert correct_printer.status_code == 200
  398. assert correct_printer.content == b"bound zip"
  399. @pytest.mark.asyncio
  400. @pytest.mark.integration
  401. async def test_legacy_bulk_download_accepts_paths_without_sizes(
  402. self,
  403. async_client: AsyncClient,
  404. printer_factory,
  405. tmp_path,
  406. ):
  407. """Existing API clients may omit the newer preflight size hints."""
  408. printer = await printer_factory()
  409. bundle_dir = tmp_path / "legacy-bundle"
  410. bundle_dir.mkdir()
  411. zip_path = bundle_dir / "printer-files.zip"
  412. zip_path.write_bytes(b"legacy zip")
  413. build_zip = AsyncMock(
  414. return_value=PrinterFilesZipResult(
  415. path=zip_path,
  416. requested=2,
  417. successful=2,
  418. failed_paths=(),
  419. total_bytes=10,
  420. )
  421. )
  422. with patch("backend.app.api.routes.printers.build_printer_files_zip", new=build_zip):
  423. response = await async_client.post(
  424. f"/api/v1/printers/{printer.id}/files/download-zip",
  425. json={"paths": ["model.gcode", "model.gcode"]},
  426. )
  427. assert response.status_code == 200
  428. assert response.content == b"legacy zip"
  429. assert build_zip.await_args.args[1:] == (["/model.gcode", "/model.gcode"], {})
  430. assert build_zip.await_args.kwargs == {"preserve_paths": False, "allow_empty": True}
  431. @pytest.mark.asyncio
  432. @pytest.mark.integration
  433. async def test_legacy_bulk_download_empty_selection_keeps_400_contract(
  434. self,
  435. async_client: AsyncClient,
  436. printer_factory,
  437. ):
  438. printer = await printer_factory()
  439. response = await async_client.post(
  440. f"/api/v1/printers/{printer.id}/files/download-zip",
  441. json={"paths": []},
  442. )
  443. assert response.status_code == 400
  444. @pytest.mark.asyncio
  445. @pytest.mark.integration
  446. @pytest.mark.parametrize("transport", ["header", "bearer"])
  447. async def test_printer_file_routes_enforce_api_key_printer_scope(
  448. self,
  449. async_client: AsyncClient,
  450. printer_factory,
  451. db_session,
  452. transport: str,
  453. ):
  454. """A key restricted to printer A must not list printer B's storage."""
  455. from backend.app.core.auth import generate_api_key
  456. from backend.app.models.api_key import APIKey
  457. from backend.app.services.bambu_ftp import FileListResult
  458. printer_a = await printer_factory(name="Scoped A", serial_number="SCOPEA000000001")
  459. printer_b = await printer_factory(name="Scoped B", serial_number="SCOPEB000000001")
  460. setup = await async_client.post(
  461. "/api/v1/auth/setup",
  462. json={
  463. "auth_enabled": True,
  464. "admin_username": "scopeadmin",
  465. "admin_password": "AdminPass1!",
  466. },
  467. )
  468. assert setup.status_code == 200, setup.text
  469. full_key, key_hash, key_prefix = generate_api_key()
  470. db_session.add(
  471. APIKey(
  472. name=f"printer-scope-{transport}",
  473. key_hash=key_hash,
  474. key_prefix=key_prefix,
  475. can_control_printer=True,
  476. printer_ids=[printer_a.id],
  477. enabled=True,
  478. )
  479. )
  480. await db_session.commit()
  481. headers = {"X-API-Key": full_key} if transport == "header" else {"Authorization": f"Bearer {full_key}"}
  482. listing = AsyncMock(return_value=FileListResult(files=[], available=True))
  483. with patch("backend.app.api.routes.printers.list_files_result_async", new=listing):
  484. denied = await async_client.get(
  485. f"/api/v1/printers/{printer_b.id}/files",
  486. headers=headers,
  487. )
  488. allowed = await async_client.get(
  489. f"/api/v1/printers/{printer_a.id}/files",
  490. headers=headers,
  491. )
  492. # Out of scope reads as missing, so the id isn't confirmed (#1727)
  493. assert denied.status_code == 404
  494. assert allowed.status_code == 200
  495. listing.assert_awaited_once()
  496. # ========================================================================
  497. # Status endpoint
  498. # ========================================================================
  499. @pytest.mark.asyncio
  500. @pytest.mark.integration
  501. async def test_get_printer_status(
  502. self, async_client: AsyncClient, printer_factory, mock_printer_manager, db_session
  503. ):
  504. """Verify printer status can be retrieved."""
  505. printer = await printer_factory()
  506. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  507. assert response.status_code == 200
  508. result = response.json()
  509. assert "connected" in result
  510. assert "state" in result
  511. @pytest.mark.asyncio
  512. @pytest.mark.integration
  513. async def test_get_printer_status_not_found(self, async_client: AsyncClient):
  514. """Verify 404 for status of non-existent printer."""
  515. response = await async_client.get("/api/v1/printers/9999/status")
  516. assert response.status_code == 404
  517. @pytest.mark.asyncio
  518. @pytest.mark.integration
  519. async def test_get_printer_status_includes_fila_switch_when_installed(
  520. self, async_client: AsyncClient, printer_factory, db_session
  521. ):
  522. """When the FTS accessory is installed, the status response must include
  523. the fila_switch object with the routing arrays. See #1162.
  524. The accessory is detected from print.device.fila_switch in MQTT;
  525. we feed a PrinterState with FilaSwitchState(installed=True, ...) and
  526. confirm it survives the schema serialization round-trip.
  527. """
  528. from unittest.mock import MagicMock, patch
  529. from backend.app.services.bambu_mqtt import FilaSwitchState, PrinterState
  530. printer = await printer_factory()
  531. state = PrinterState()
  532. state.connected = True
  533. state.state = "IDLE"
  534. state.fila_switch = FilaSwitchState(
  535. installed=True,
  536. in_slots=[-1, 2],
  537. out_extruders=[0, 1],
  538. stat=0,
  539. info=2,
  540. )
  541. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  542. mock_pm.get_status = MagicMock(return_value=state)
  543. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  544. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  545. assert response.status_code == 200
  546. result = response.json()
  547. assert result["fila_switch"] is not None
  548. assert result["fila_switch"]["installed"] is True
  549. assert result["fila_switch"]["in_slots"] == [-1, 2]
  550. assert result["fila_switch"]["out_extruders"] == [0, 1]
  551. assert result["fila_switch"]["stat"] == 0
  552. assert result["fila_switch"]["info"] == 2
  553. @pytest.mark.asyncio
  554. @pytest.mark.integration
  555. async def test_status_reports_switch_readiness_on_the_first_load(self, async_client: AsyncClient, printer_factory):
  556. """``ready`` has to be computed by the REST route, not just the WebSocket.
  557. This response is what the page has before any push arrives. Leaving the
  558. field at its default would tell a correctly set-up machine that its
  559. switch is not set up, and the AMS menu refuses Load on that.
  560. """
  561. from unittest.mock import MagicMock, patch
  562. from backend.app.services.bambu_mqtt import FilaSwitchState, PrinterState
  563. printer = await printer_factory()
  564. state = PrinterState()
  565. state.connected = True
  566. state.state = "IDLE"
  567. state.fila_switch = FilaSwitchState(installed=True)
  568. state.raw_data = {"ams": [{"id": "0", "tray": []}, {"id": "1", "tray": []}]}
  569. state.ams_switch_inlet = {"0": "A"}
  570. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  571. mock_pm.get_status = MagicMock(return_value=state)
  572. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  573. unbound = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  574. state.ams_switch_inlet = {"0": "A", "1": "B"}
  575. bound = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  576. assert unbound.json()["fila_switch"]["ready"] is False
  577. assert bound.json()["fila_switch"]["ready"] is True
  578. @pytest.mark.asyncio
  579. @pytest.mark.integration
  580. async def test_status_reports_which_hotend_holds_which_slot(self, async_client: AsyncClient, printer_factory):
  581. """Also needed on the first load: it decides which hotend Load may offer."""
  582. from unittest.mock import MagicMock, patch
  583. from backend.app.services.bambu_mqtt import ExtruderSlot, PrinterState
  584. printer = await printer_factory()
  585. state = PrinterState()
  586. state.connected = True
  587. state.state = "IDLE"
  588. state.extruder_slots = {
  589. 0: ExtruderSlot(ams_id=0, slot_id=2, has_filament=True),
  590. 1: ExtruderSlot(),
  591. }
  592. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  593. mock_pm.get_status = MagicMock(return_value=state)
  594. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  595. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  596. assert response.json()["extruder_slots"] == {
  597. "0": {"ams_id": 0, "slot_id": 2, "has_filament": True},
  598. "1": {"ams_id": None, "slot_id": None, "has_filament": False},
  599. }
  600. @pytest.mark.asyncio
  601. @pytest.mark.integration
  602. async def test_status_reports_the_trays_the_print_drew_from(self, async_client: AsyncClient, printer_factory):
  603. """Restored after a restart as lists, recorded live as tuples: both serialise alike."""
  604. from unittest.mock import MagicMock, patch
  605. from backend.app.services.bambu_mqtt import PrinterState
  606. printer = await printer_factory()
  607. state = PrinterState()
  608. state.connected = True
  609. state.state = "RUNNING"
  610. state.tray_change_log = [[2, 0], (1, 350)]
  611. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  612. mock_pm.get_status = MagicMock(return_value=state)
  613. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  614. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  615. assert response.json()["tray_change_log"] == [[2, 0], [1, 350]]
  616. @pytest.mark.asyncio
  617. @pytest.mark.integration
  618. async def test_cover_uses_dispatched_plate_when_gcode_file_lacks_path(
  619. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  620. ):
  621. """When firmware drops the plate path from gcode_file (e.g. P1S
  622. 01.10.00.00, #1166), the dispatched-plate record must take precedence
  623. and serve plate 4's thumbnail instead of falling back to plate_1.png."""
  624. import io
  625. import zipfile
  626. from unittest.mock import MagicMock, patch
  627. from backend.app.services.bambu_ftp import cache_3mf_download
  628. from backend.app.services.bambu_mqtt import PrinterState
  629. printer = await printer_factory()
  630. # Build a 3MF that mimics a "true" multi-plate archive: thumbnails
  631. # for plates 1..4 are all present, gcode files for plates 1..4 are
  632. # all present. Without the dispatch record we'd default to plate_1.png.
  633. threemf_path = tmp_path / "MyModel.3mf"
  634. with zipfile.ZipFile(threemf_path, "w") as zf:
  635. for plate in range(1, 5):
  636. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  637. zf.writestr(f"Metadata/plate_{plate}.gcode", f"; plate {plate} gcode\n")
  638. cache_3mf_download(printer.id, "MyModel.3mf", threemf_path)
  639. state = PrinterState()
  640. state.connected = True
  641. state.state = "RUNNING"
  642. state.subtask_name = "MyModel"
  643. state.gcode_file = "MyModel.3mf" # firmware drops plate path
  644. state.dispatched_plate_id = 4
  645. state.dispatched_subtask = "MyModel"
  646. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  647. mock_pm.get_status = MagicMock(return_value=state)
  648. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  649. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  650. assert response.status_code == 200
  651. assert response.content == b"PLATE_4_PNG"
  652. @pytest.mark.asyncio
  653. @pytest.mark.integration
  654. async def test_cover_pick_view_serves_active_plate_object_mask(
  655. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  656. ):
  657. """The skip-items UI needs the slicer's exact object-ID mask, not an
  658. inferred bounding box, so a plate click resolves to the firmware ID."""
  659. import zipfile
  660. from unittest.mock import MagicMock, patch
  661. from backend.app.services.bambu_ftp import cache_3mf_download
  662. from backend.app.services.bambu_mqtt import PrinterState
  663. printer = await printer_factory()
  664. threemf_path = tmp_path / "PickMask.3mf"
  665. with zipfile.ZipFile(threemf_path, "w") as zf:
  666. zf.writestr("Metadata/pick_1.png", b"PICK_ONE")
  667. zf.writestr("Metadata/pick_3.png", b"PICK_THREE")
  668. zf.writestr("Metadata/plate_3.gcode", "; active plate\n")
  669. cache_3mf_download(printer.id, "PickMask.3mf", threemf_path)
  670. state = PrinterState()
  671. state.connected = True
  672. state.state = "RUNNING"
  673. state.subtask_name = "PickMask"
  674. state.gcode_file = "PickMask.3mf"
  675. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  676. mock_pm.get_status = MagicMock(return_value=state)
  677. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
  678. assert response.status_code == 200
  679. assert response.content == b"PICK_THREE"
  680. @pytest.mark.asyncio
  681. @pytest.mark.integration
  682. async def test_cover_pick_view_404s_rather_than_serving_a_render(
  683. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  684. ):
  685. """A mask is coordinates, not decoration. Archives without pick_N.png
  686. (Handy jobs, older slicers) must 404 so the UI drops to the checklist —
  687. every other view's fallback to a rendered thumbnail would be decoded as
  688. object IDs here, and a click would skip an arbitrary object."""
  689. import zipfile
  690. from unittest.mock import MagicMock, patch
  691. from backend.app.services.bambu_ftp import cache_3mf_download
  692. from backend.app.services.bambu_mqtt import PrinterState
  693. printer = await printer_factory()
  694. threemf_path = tmp_path / "NoMask.3mf"
  695. with zipfile.ZipFile(threemf_path, "w") as zf:
  696. zf.writestr("Metadata/top_1.png", b"TOP_RENDER")
  697. zf.writestr("Metadata/plate_1.png", b"PLATE_RENDER")
  698. zf.writestr("Metadata/plate_1.gcode", "; active plate\n")
  699. cache_3mf_download(printer.id, "NoMask.3mf", threemf_path)
  700. state = PrinterState()
  701. state.connected = True
  702. state.state = "RUNNING"
  703. state.subtask_name = "NoMask"
  704. state.gcode_file = "NoMask.3mf"
  705. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  706. mock_pm.get_status = MagicMock(return_value=state)
  707. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
  708. assert response.status_code == 404
  709. assert b"RENDER" not in response.content
  710. @pytest.mark.asyncio
  711. @pytest.mark.integration
  712. async def test_cover_pick_view_does_not_borrow_another_plates_mask(
  713. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  714. ):
  715. """Plate 1's mask over plate 3's layout resolves clicks to whatever
  716. occupied that pixel on a different plate, so the active plate's mask is
  717. the only acceptable answer."""
  718. import zipfile
  719. from unittest.mock import MagicMock, patch
  720. from backend.app.services.bambu_ftp import cache_3mf_download
  721. from backend.app.services.bambu_mqtt import PrinterState
  722. printer = await printer_factory()
  723. threemf_path = tmp_path / "OtherPlate.3mf"
  724. with zipfile.ZipFile(threemf_path, "w") as zf:
  725. zf.writestr("Metadata/pick_1.png", b"PICK_ONE")
  726. zf.writestr("Metadata/top_3.png", b"TOP_THREE")
  727. zf.writestr("Metadata/plate_3.gcode", "; active plate\n")
  728. cache_3mf_download(printer.id, "OtherPlate.3mf", threemf_path)
  729. state = PrinterState()
  730. state.connected = True
  731. state.state = "RUNNING"
  732. state.subtask_name = "OtherPlate"
  733. state.gcode_file = "OtherPlate.3mf"
  734. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  735. mock_pm.get_status = MagicMock(return_value=state)
  736. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
  737. assert response.status_code == 404
  738. assert response.content != b"PICK_ONE"
  739. @pytest.mark.asyncio
  740. @pytest.mark.integration
  741. async def test_cover_3mf_scan_fallback_for_per_plate_archive(
  742. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  743. ):
  744. """Per-plate archives sliced separately in Bambu Studio contain a
  745. single Metadata/plate_N.gcode (the active plate) but bundle thumbnails
  746. for every plate. With no dispatch record (e.g. dispatched via Studio
  747. directly) and no plate path in gcode_file, the route must scan the
  748. 3MF and pick plate N's thumbnail. See #1166 option 4."""
  749. import zipfile
  750. from unittest.mock import MagicMock, patch
  751. from backend.app.services.bambu_ftp import cache_3mf_download
  752. from backend.app.services.bambu_mqtt import PrinterState
  753. printer = await printer_factory()
  754. # Per-plate archive: thumbnails for all plates, gcode for plate 3 only.
  755. threemf_path = tmp_path / "PerPlate.3mf"
  756. with zipfile.ZipFile(threemf_path, "w") as zf:
  757. for plate in range(1, 5):
  758. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  759. zf.writestr("Metadata/plate_3.gcode", "; only plate 3 has gcode\n")
  760. cache_3mf_download(printer.id, "PerPlate.3mf", threemf_path)
  761. state = PrinterState()
  762. state.connected = True
  763. state.state = "RUNNING"
  764. state.subtask_name = "PerPlate"
  765. state.gcode_file = "PerPlate.3mf"
  766. # No dispatch record (Studio-direct dispatch).
  767. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  768. mock_pm.get_status = MagicMock(return_value=state)
  769. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  770. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  771. assert response.status_code == 200
  772. assert response.content == b"PLATE_3_PNG"
  773. @pytest.mark.asyncio
  774. @pytest.mark.integration
  775. async def test_cover_negative_cache_skips_repeat_ftp_fanout(
  776. self, async_client: AsyncClient, printer_factory, db_session
  777. ):
  778. """#1420: when every FTP path returns 550 for the current subtask, the
  779. next request for the same subtask must short-circuit to 404 instead of
  780. replaying the 8-path FTP fan-out (which starves the printer's single
  781. FTP socket and flooded the user's logs)."""
  782. from unittest.mock import AsyncMock, MagicMock, patch
  783. from backend.app.api.routes.printers import _cover_404_cache, _cover_cache
  784. from backend.app.services.bambu_mqtt import PrinterState
  785. printer = await printer_factory()
  786. _cover_cache.pop(printer.id, None)
  787. _cover_404_cache.pop(printer.id, None)
  788. state = PrinterState()
  789. state.connected = True
  790. state.state = "RUNNING"
  791. state.subtask_name = "OrphanPrint"
  792. state.gcode_file = "OrphanPrint.3mf"
  793. ftp_mock = AsyncMock(return_value=False)
  794. with (
  795. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  796. patch("backend.app.api.routes.printers.download_file_try_paths_async", ftp_mock),
  797. ):
  798. mock_pm.get_status = MagicMock(return_value=state)
  799. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  800. r1 = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  801. r2 = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  802. assert r1.status_code == 404
  803. assert r2.status_code == 404
  804. # First call retries internally; second call must short-circuit before FTP.
  805. first_call_count = ftp_mock.await_count
  806. assert first_call_count >= 1
  807. # Second request didn't add to the count: the negative cache held.
  808. assert ftp_mock.await_count == first_call_count
  809. @pytest.mark.asyncio
  810. @pytest.mark.integration
  811. async def test_get_printer_status_omits_fila_switch_when_not_installed(
  812. self, async_client: AsyncClient, printer_factory, db_session
  813. ):
  814. """Without the FTS accessory, fila_switch must be null so the frontend
  815. keeps applying the per-extruder filter on regular dual-nozzle printers."""
  816. from unittest.mock import MagicMock, patch
  817. from backend.app.services.bambu_mqtt import PrinterState
  818. printer = await printer_factory()
  819. state = PrinterState()
  820. state.connected = True
  821. state.state = "IDLE"
  822. # default fila_switch — installed = False
  823. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  824. mock_pm.get_status = MagicMock(return_value=state)
  825. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  826. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  827. assert response.status_code == 200
  828. result = response.json()
  829. assert result["fila_switch"] is None
  830. # ========================================================================
  831. # Test connection endpoint
  832. # ========================================================================
  833. class TestCoverPoolHygiene:
  834. """Regression guard for the /cover DB-connection leak (issue #2572)."""
  835. def test_cover_does_not_hold_a_get_db_session(self):
  836. """The cover endpoint must NOT take a ``Depends(get_db)`` session.
  837. ``get_db`` is a ``yield`` dependency, so its session stays open for the
  838. whole request — including the 3MF cover download (up to 8 remote paths ×
  839. retries with backoff, minutes under FTP contention), pinning one pooled
  840. DB connection ``idle in transaction`` the entire time. The endpoint
  841. fetches the printer in a short-lived ``async with async_session()`` and
  842. releases the connection before the FTP work. If someone re-adds a
  843. ``Depends(get_db)`` param, this fails.
  844. """
  845. import inspect
  846. from backend.app.api.routes.printers import get_db, get_printer_cover
  847. for name, param in inspect.signature(get_printer_cover).parameters.items():
  848. dependency = getattr(param.default, "dependency", None)
  849. assert dependency is not get_db, (
  850. f"get_printer_cover re-introduced a get_db-held session via parameter {name!r} — "
  851. "it would stay open for the entire FTP cover download (issue #2572)"
  852. )
  853. class TestPrinterDataIntegrity:
  854. """Tests for printer data integrity."""
  855. @pytest.mark.asyncio
  856. @pytest.mark.integration
  857. async def test_printer_stores_all_fields(self, async_client: AsyncClient, printer_factory, db_session):
  858. """Verify printer stores all fields correctly."""
  859. printer = await printer_factory(
  860. name="Full Test Printer",
  861. serial_number="00M09A444444444",
  862. ip_address="192.168.1.150",
  863. model="P1S",
  864. is_active=True,
  865. auto_archive=False,
  866. )
  867. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  868. assert response.status_code == 200
  869. result = response.json()
  870. assert result["name"] == "Full Test Printer"
  871. assert result["serial_number"] == "00M09A444444444"
  872. assert result["ip_address"] == "192.168.1.150"
  873. assert result["model"] == "P1S"
  874. assert result["is_active"] is True
  875. assert result["auto_archive"] is False
  876. @pytest.mark.asyncio
  877. @pytest.mark.integration
  878. async def test_printer_update_persists(self, async_client: AsyncClient, printer_factory, db_session):
  879. """CRITICAL: Verify printer updates persist."""
  880. printer = await printer_factory(name="Original", is_active=True)
  881. # Update
  882. await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated", "is_active": False})
  883. # Verify persistence
  884. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  885. result = response.json()
  886. assert result["name"] == "Updated"
  887. assert result["is_active"] is False
  888. # ========================================================================
  889. # Refresh status endpoint
  890. # ========================================================================
  891. @pytest.mark.asyncio
  892. @pytest.mark.integration
  893. async def test_refresh_status_not_found(self, async_client: AsyncClient):
  894. """Verify 404 for non-existent printer."""
  895. response = await async_client.post("/api/v1/printers/99999/refresh-status")
  896. assert response.status_code == 404
  897. @pytest.mark.asyncio
  898. @pytest.mark.integration
  899. async def test_refresh_status_not_connected(self, async_client: AsyncClient, printer_factory):
  900. """Verify 400 when printer is not connected."""
  901. printer = await printer_factory(name="Disconnected Printer")
  902. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  903. mock_pm.request_status_update.return_value = False
  904. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  905. assert response.status_code == 400
  906. assert "not connected" in response.json()["detail"].lower()
  907. @pytest.mark.asyncio
  908. @pytest.mark.integration
  909. async def test_refresh_status_success(self, async_client: AsyncClient, printer_factory):
  910. """Verify successful refresh request."""
  911. printer = await printer_factory(name="Connected Printer")
  912. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  913. mock_pm.request_status_update.return_value = True
  914. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  915. assert response.status_code == 200
  916. assert response.json()["status"] == "refresh_requested"
  917. mock_pm.request_status_update.assert_called_once_with(printer.id)
  918. # ========================================================================
  919. # Current print user endpoint (Issue #206)
  920. # ========================================================================
  921. @pytest.mark.asyncio
  922. @pytest.mark.integration
  923. async def test_get_current_print_user_not_found(self, async_client: AsyncClient):
  924. """Verify 404 for non-existent printer."""
  925. response = await async_client.get("/api/v1/printers/99999/current-print-user")
  926. assert response.status_code == 404
  927. @pytest.mark.asyncio
  928. @pytest.mark.integration
  929. async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):
  930. """Verify empty object returned when no user is tracked."""
  931. printer = await printer_factory(name="Test Printer")
  932. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  933. mock_pm.get_current_print_user.return_value = None
  934. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  935. assert response.status_code == 200
  936. assert response.json() == {}
  937. @pytest.mark.asyncio
  938. @pytest.mark.integration
  939. async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):
  940. """Verify user info is returned when tracked."""
  941. printer = await printer_factory(name="Test Printer")
  942. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  943. mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser", "queue_item_id": 5}
  944. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  945. assert response.status_code == 200
  946. assert response.json() == {"user_id": 42, "username": "testuser"}
  947. class TestPrintControlAPI:
  948. """Integration tests for print control endpoints (stop, pause, resume)."""
  949. # ========================================================================
  950. # Stop print endpoint
  951. # ========================================================================
  952. @pytest.mark.asyncio
  953. @pytest.mark.integration
  954. async def test_stop_print_not_found(self, async_client: AsyncClient):
  955. """Verify 404 for non-existent printer."""
  956. response = await async_client.post("/api/v1/printers/99999/print/stop")
  957. assert response.status_code == 404
  958. @pytest.mark.asyncio
  959. @pytest.mark.integration
  960. async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):
  961. """Verify error when printer is not connected."""
  962. printer = await printer_factory(name="Disconnected Printer")
  963. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  964. mock_pm.get_client.return_value = None
  965. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  966. assert response.status_code == 400
  967. assert "not connected" in response.json()["detail"].lower()
  968. @pytest.mark.asyncio
  969. @pytest.mark.integration
  970. async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):
  971. """Verify successful stop print request."""
  972. printer = await printer_factory(name="Printing Printer")
  973. from backend.app.main import _user_stopped_printers
  974. _user_stopped_printers.discard(printer.id)
  975. with (
  976. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  977. patch("backend.app.services.print_control.printer_manager.stop_print", return_value=True) as send,
  978. ):
  979. mock_pm.get_client.return_value = MagicMock()
  980. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  981. assert response.status_code == 200
  982. assert response.json()["success"] is True
  983. send.assert_called_once_with(printer.id)
  984. # Stopped by the user, so the printer's "failed" ends the print as cancelled.
  985. assert printer.id in _user_stopped_printers
  986. _user_stopped_printers.discard(printer.id)
  987. # ========================================================================
  988. # Pause print endpoint
  989. # ========================================================================
  990. @pytest.mark.asyncio
  991. @pytest.mark.integration
  992. async def test_pause_print_not_found(self, async_client: AsyncClient):
  993. """Verify 404 for non-existent printer."""
  994. response = await async_client.post("/api/v1/printers/99999/print/pause")
  995. assert response.status_code == 404
  996. @pytest.mark.asyncio
  997. @pytest.mark.integration
  998. async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):
  999. """Verify error when printer is not connected."""
  1000. printer = await printer_factory(name="Disconnected Printer")
  1001. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1002. mock_pm.get_client.return_value = None
  1003. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  1004. assert response.status_code == 400
  1005. assert "not connected" in response.json()["detail"].lower()
  1006. @pytest.mark.asyncio
  1007. @pytest.mark.integration
  1008. async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):
  1009. """Verify successful pause print request."""
  1010. printer = await printer_factory(name="Printing Printer")
  1011. mock_client = MagicMock()
  1012. mock_client.pause_print.return_value = True
  1013. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1014. mock_pm.get_client.return_value = mock_client
  1015. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  1016. assert response.status_code == 200
  1017. assert response.json()["success"] is True
  1018. mock_client.pause_print.assert_called_once()
  1019. # ========================================================================
  1020. # Resume print endpoint
  1021. # ========================================================================
  1022. @pytest.mark.asyncio
  1023. @pytest.mark.integration
  1024. async def test_resume_print_not_found(self, async_client: AsyncClient):
  1025. """Verify 404 for non-existent printer."""
  1026. response = await async_client.post("/api/v1/printers/99999/print/resume")
  1027. assert response.status_code == 404
  1028. @pytest.mark.asyncio
  1029. @pytest.mark.integration
  1030. async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):
  1031. """Verify error when printer is not connected."""
  1032. printer = await printer_factory(name="Disconnected Printer")
  1033. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1034. mock_pm.get_client.return_value = None
  1035. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  1036. assert response.status_code == 400
  1037. assert "not connected" in response.json()["detail"].lower()
  1038. @pytest.mark.asyncio
  1039. @pytest.mark.integration
  1040. async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):
  1041. """Verify successful resume print request."""
  1042. printer = await printer_factory(name="Paused Printer")
  1043. mock_client = MagicMock()
  1044. mock_client.resume_print.return_value = True
  1045. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1046. mock_pm.get_client.return_value = mock_client
  1047. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  1048. assert response.status_code == 200
  1049. assert response.json()["success"] is True
  1050. mock_client.resume_print.assert_called_once()
  1051. class TestAMSRefreshAPI:
  1052. """Integration tests for AMS slot refresh endpoint."""
  1053. @pytest.mark.asyncio
  1054. @pytest.mark.integration
  1055. async def test_ams_refresh_not_found(self, async_client: AsyncClient):
  1056. """Verify 404 for non-existent printer."""
  1057. response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh")
  1058. assert response.status_code == 404
  1059. @pytest.mark.asyncio
  1060. @pytest.mark.integration
  1061. async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):
  1062. """Verify error when printer is not connected."""
  1063. printer = await printer_factory(name="Disconnected Printer")
  1064. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1065. mock_pm.get_client.return_value = None
  1066. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  1067. assert response.status_code == 400
  1068. assert "not connected" in response.json()["detail"].lower()
  1069. @pytest.mark.asyncio
  1070. @pytest.mark.integration
  1071. async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):
  1072. """Verify successful AMS refresh request."""
  1073. printer = await printer_factory(name="Printer with AMS")
  1074. mock_client = MagicMock()
  1075. mock_client.ams_refresh_tray = AsyncMock(return_value=(True, "Refreshing AMS 0 tray 1"))
  1076. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1077. mock_pm.get_client.return_value = mock_client
  1078. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh")
  1079. assert response.status_code == 200
  1080. result = response.json()
  1081. assert result["success"] is True
  1082. mock_client.ams_refresh_tray.assert_called_once_with(0, 1)
  1083. @pytest.mark.asyncio
  1084. @pytest.mark.integration
  1085. async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):
  1086. """Verify error when filament is loaded (can't refresh while loaded)."""
  1087. printer = await printer_factory(name="Printer with AMS")
  1088. mock_client = MagicMock()
  1089. mock_client.ams_refresh_tray = AsyncMock(return_value=(False, "Please unload filament first"))
  1090. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1091. mock_pm.get_client.return_value = mock_client
  1092. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  1093. assert response.status_code == 400
  1094. assert "unload" in response.json()["detail"].lower()
  1095. class TestAMSLoadUnloadAPI:
  1096. """Integration tests for AMS load / unload endpoints (#891)."""
  1097. # ── load ─────────────────────────────────────────────────────────────────
  1098. @pytest.mark.asyncio
  1099. @pytest.mark.integration
  1100. async def test_load_invalid_tray_id(self, async_client: AsyncClient, printer_factory):
  1101. """tray_id outside {0..15, 254, 255} is rejected."""
  1102. printer = await printer_factory(name="P")
  1103. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=99")
  1104. assert response.status_code == 400
  1105. @pytest.mark.asyncio
  1106. @pytest.mark.integration
  1107. @pytest.mark.parametrize("tray_id", [128, 135])
  1108. async def test_load_an_ams_ht_slot(self, async_client: AsyncClient, printer_factory, tray_id):
  1109. """An AMS-HT tray is its unit id; refusing it made Load impossible there."""
  1110. printer = await printer_factory(name="H2C")
  1111. client = MagicMock()
  1112. client.ams_load_filament.return_value = True
  1113. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1114. mock_pm.get_client.return_value = client
  1115. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id={tray_id}")
  1116. assert response.status_code == 200, response.text
  1117. client.ams_load_filament.assert_called_once_with(tray_id, extruder_id=None)
  1118. assert response.json()["message"] == f"Loading filament from AMS HT-{'A' if tray_id == 128 else 'H'}"
  1119. @pytest.mark.asyncio
  1120. @pytest.mark.integration
  1121. @pytest.mark.parametrize("tray_id", [127, 136, 512])
  1122. async def test_load_refuses_ids_next_to_the_ams_ht_range(self, async_client: AsyncClient, printer_factory, tray_id):
  1123. printer = await printer_factory(name="H2C")
  1124. client = MagicMock()
  1125. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1126. mock_pm.get_client.return_value = client
  1127. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id={tray_id}")
  1128. assert response.status_code == 400
  1129. assert "128..135 (AMS-HT)" in response.json()["detail"]
  1130. client.ams_load_filament.assert_not_called()
  1131. @pytest.mark.asyncio
  1132. @pytest.mark.integration
  1133. async def test_load_not_found(self, async_client: AsyncClient):
  1134. response = await async_client.post("/api/v1/printers/99999/ams/load?tray_id=0")
  1135. assert response.status_code == 404
  1136. @pytest.mark.asyncio
  1137. @pytest.mark.integration
  1138. async def test_load_not_connected(self, async_client: AsyncClient, printer_factory):
  1139. printer = await printer_factory(name="Disconnected")
  1140. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1141. mock_pm.get_client.return_value = None
  1142. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  1143. assert response.status_code == 400
  1144. assert "not connected" in response.json()["detail"].lower()
  1145. @pytest.mark.asyncio
  1146. @pytest.mark.integration
  1147. async def test_load_ams_slot_success(self, async_client: AsyncClient, printer_factory):
  1148. """tray_id=5 → AMS 1 slot 2 (1-indexed in the message)."""
  1149. printer = await printer_factory(name="P")
  1150. mock_client = MagicMock()
  1151. mock_client.ams_load_filament.return_value = True
  1152. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1153. mock_pm.get_client.return_value = mock_client
  1154. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5")
  1155. assert response.status_code == 200
  1156. mock_client.ams_load_filament.assert_called_once_with(5, extruder_id=None)
  1157. assert "AMS 1" in response.json()["message"]
  1158. @pytest.mark.asyncio
  1159. @pytest.mark.integration
  1160. async def test_load_external_left_success(self, async_client: AsyncClient, printer_factory):
  1161. """tray_id=254 → external spool / Ext-L."""
  1162. printer = await printer_factory(name="P")
  1163. mock_client = MagicMock()
  1164. mock_client.ams_load_filament.return_value = True
  1165. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1166. mock_pm.get_client.return_value = mock_client
  1167. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=254")
  1168. assert response.status_code == 200
  1169. mock_client.ams_load_filament.assert_called_once_with(254, extruder_id=None)
  1170. assert "external" in response.json()["message"].lower()
  1171. @pytest.mark.asyncio
  1172. @pytest.mark.integration
  1173. async def test_load_external_right_success(self, async_client: AsyncClient, printer_factory):
  1174. """tray_id=255 → Ext-R on dual-nozzle H2D."""
  1175. printer = await printer_factory(name="H2D")
  1176. mock_client = MagicMock()
  1177. mock_client.ams_load_filament.return_value = True
  1178. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1179. mock_pm.get_client.return_value = mock_client
  1180. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=255")
  1181. assert response.status_code == 200
  1182. mock_client.ams_load_filament.assert_called_once_with(255, extruder_id=None)
  1183. assert "Ext-R" in response.json()["message"]
  1184. @pytest.mark.asyncio
  1185. @pytest.mark.integration
  1186. async def test_load_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  1187. printer = await printer_factory(name="P")
  1188. mock_client = MagicMock()
  1189. mock_client.ams_load_filament.return_value = False
  1190. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1191. mock_pm.get_client.return_value = mock_client
  1192. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  1193. assert response.status_code == 500
  1194. assert "failed" in response.json()["detail"].lower()
  1195. @pytest.mark.asyncio
  1196. @pytest.mark.integration
  1197. async def test_load_forwards_the_chosen_hotend(self, async_client: AsyncClient, printer_factory):
  1198. """A printer with a Filament Track Switch has to name the hotend to feed."""
  1199. printer = await printer_factory(name="P")
  1200. mock_client = MagicMock()
  1201. mock_client.ams_load_filament.return_value = True
  1202. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1203. mock_pm.get_client.return_value = mock_client
  1204. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5&extruder_id=1")
  1205. assert response.status_code == 200
  1206. mock_client.ams_load_filament.assert_called_once_with(5, extruder_id=1)
  1207. @pytest.mark.asyncio
  1208. @pytest.mark.integration
  1209. async def test_load_rejects_a_hotend_that_does_not_exist(self, async_client: AsyncClient, printer_factory):
  1210. """Only 0 and 1 are real hotends; anything else is a client bug."""
  1211. printer = await printer_factory(name="P")
  1212. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5&extruder_id=2")
  1213. assert response.status_code == 422
  1214. # ── unload ───────────────────────────────────────────────────────────────
  1215. @pytest.mark.asyncio
  1216. @pytest.mark.integration
  1217. async def test_unload_not_found(self, async_client: AsyncClient):
  1218. response = await async_client.post("/api/v1/printers/99999/ams/unload")
  1219. assert response.status_code == 404
  1220. @pytest.mark.asyncio
  1221. @pytest.mark.integration
  1222. async def test_unload_not_connected(self, async_client: AsyncClient, printer_factory):
  1223. printer = await printer_factory(name="Disconnected")
  1224. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1225. mock_pm.get_client.return_value = None
  1226. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  1227. assert response.status_code == 400
  1228. assert "not connected" in response.json()["detail"].lower()
  1229. @pytest.mark.asyncio
  1230. @pytest.mark.integration
  1231. async def test_unload_success(self, async_client: AsyncClient, printer_factory):
  1232. printer = await printer_factory(name="P")
  1233. mock_client = MagicMock()
  1234. mock_client.ams_unload_filament.return_value = True
  1235. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1236. mock_pm.get_client.return_value = mock_client
  1237. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  1238. assert response.status_code == 200
  1239. mock_client.ams_unload_filament.assert_called_once_with(None)
  1240. assert response.json()["success"] is True
  1241. @pytest.mark.asyncio
  1242. @pytest.mark.integration
  1243. async def test_unload_forwards_the_slot(self, async_client: AsyncClient, printer_factory):
  1244. """The slot is what tells a dual-nozzle printer which hotend to unload."""
  1245. printer = await printer_factory(name="P")
  1246. mock_client = MagicMock()
  1247. mock_client.ams_unload_filament.return_value = True
  1248. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1249. mock_pm.get_client.return_value = mock_client
  1250. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload?tray_id=2")
  1251. assert response.status_code == 200
  1252. mock_client.ams_unload_filament.assert_called_once_with(2)
  1253. @pytest.mark.asyncio
  1254. @pytest.mark.integration
  1255. async def test_unload_of_an_unloaded_slot_is_a_conflict_not_a_fault(
  1256. self, async_client: AsyncClient, printer_factory
  1257. ):
  1258. """Clicking Unload on an idle slot is a no-op the operator can understand.
  1259. A 500 would read as a broken printer; the menu is per-slot and picking a
  1260. slot no hotend is fed from is an ordinary mistake.
  1261. """
  1262. printer = await printer_factory(name="P")
  1263. mock_client = MagicMock()
  1264. mock_client.ams_unload_filament.return_value = False
  1265. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1266. mock_pm.get_client.return_value = mock_client
  1267. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload?tray_id=2")
  1268. assert response.status_code == 409
  1269. @pytest.mark.asyncio
  1270. @pytest.mark.integration
  1271. async def test_unload_rejects_an_invalid_slot(self, async_client: AsyncClient, printer_factory):
  1272. printer = await printer_factory(name="P")
  1273. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload?tray_id=99")
  1274. assert response.status_code == 400
  1275. @pytest.mark.asyncio
  1276. @pytest.mark.integration
  1277. async def test_unload_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  1278. printer = await printer_factory(name="P")
  1279. mock_client = MagicMock()
  1280. mock_client.ams_unload_filament.return_value = False
  1281. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1282. mock_pm.get_client.return_value = mock_client
  1283. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  1284. assert response.status_code == 500
  1285. assert "failed" in response.json()["detail"].lower()
  1286. class TestConfigureAMSSlotAPI:
  1287. """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
  1288. @pytest.mark.asyncio
  1289. @pytest.mark.integration
  1290. async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
  1291. """Verify error when printer is not connected."""
  1292. printer = await printer_factory(name="Disconnected")
  1293. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1294. mock_pm.get_client.return_value = None
  1295. response = await async_client.post(
  1296. f"/api/v1/printers/{printer.id}/slots/0/0/configure",
  1297. params={
  1298. "tray_info_idx": "GFL99",
  1299. "tray_type": "PLA",
  1300. "tray_sub_brands": "PLA Basic",
  1301. "tray_color": "FF0000FF",
  1302. "nozzle_temp_min": 190,
  1303. "nozzle_temp_max": 230,
  1304. },
  1305. )
  1306. assert response.status_code == 400
  1307. assert "not connected" in response.json()["detail"].lower()
  1308. @pytest.mark.asyncio
  1309. @pytest.mark.integration
  1310. async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
  1311. """Standard Bambu GF* filament IDs are sent as-is."""
  1312. printer = await printer_factory(name="H2D")
  1313. mock_client = MagicMock()
  1314. mock_client.ams_set_filament_setting.return_value = True
  1315. mock_client.extrusion_cali_sel.return_value = True
  1316. mock_client.request_status_update.return_value = True
  1317. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1318. mock_pm.get_client.return_value = mock_client
  1319. mock_pm.get_status.return_value = None # No existing state
  1320. response = await async_client.post(
  1321. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  1322. params={
  1323. "tray_info_idx": "GFL05",
  1324. "tray_type": "PLA",
  1325. "tray_sub_brands": "PLA Basic",
  1326. "tray_color": "FFFFFFFF",
  1327. "nozzle_temp_min": 190,
  1328. "nozzle_temp_max": 230,
  1329. },
  1330. )
  1331. assert response.status_code == 200
  1332. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1333. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  1334. @pytest.mark.asyncio
  1335. @pytest.mark.integration
  1336. @pytest.mark.parametrize("cali_idx", [-1, 9354])
  1337. async def test_configure_records_the_k_profile_pick(self, async_client: AsyncClient, printer_factory, cali_idx):
  1338. """A Default pick must not be undone by the lost-selection check (#3219)."""
  1339. printer = await printer_factory(name="X1C")
  1340. mock_client = MagicMock()
  1341. mock_client.ams_set_filament_setting.return_value = True
  1342. mock_client.extrusion_cali_sel.return_value = True
  1343. mock_client.request_status_update.return_value = True
  1344. with (
  1345. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1346. patch("backend.app.api.routes.printers.kprofile_drift.note_slot_configured") as note,
  1347. ):
  1348. mock_pm.get_client.return_value = mock_client
  1349. mock_pm.get_status.return_value = None
  1350. response = await async_client.post(
  1351. f"/api/v1/printers/{printer.id}/slots/1/2/configure",
  1352. params={
  1353. "tray_info_idx": "GFL05",
  1354. "tray_type": "PLA",
  1355. "tray_sub_brands": "PLA Basic",
  1356. "tray_color": "FFFFFFFF",
  1357. "nozzle_temp_min": 190,
  1358. "nozzle_temp_max": 230,
  1359. "cali_idx": cali_idx,
  1360. },
  1361. )
  1362. assert response.status_code == 200
  1363. note.assert_called_once_with(printer.id, 1, 2, cali_idx)
  1364. @pytest.mark.asyncio
  1365. @pytest.mark.integration
  1366. async def test_configure_builtin_empty_setting_id_is_derived(self, async_client: AsyncClient, printer_factory):
  1367. """A built-in preset (GF* tray_info_idx, empty setting_id) gets a derived GFS* setting_id (#2604).
  1368. The Configure AMS Slot modal sends built-in / local / Orca-generic presets with an
  1369. empty setting_id. Publishing a filament-id-without-setting-id slot makes the printer
  1370. treat it as half configured and revert to its previous profile, so the route must
  1371. back-fill setting_id from the resolved tray_info_idx.
  1372. """
  1373. printer = await printer_factory(name="X1C")
  1374. mock_client = MagicMock()
  1375. mock_client.ams_set_filament_setting.return_value = True
  1376. mock_client.extrusion_cali_sel.return_value = True
  1377. mock_client.request_status_update.return_value = True
  1378. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1379. mock_pm.get_client.return_value = mock_client
  1380. mock_pm.get_status.return_value = None
  1381. response = await async_client.post(
  1382. f"/api/v1/printers/{printer.id}/slots/0/1/configure",
  1383. params={
  1384. "tray_info_idx": "GFB99", # Generic ABS, built-in
  1385. "tray_type": "ABS",
  1386. "tray_sub_brands": "Generic ABS",
  1387. "tray_color": "000000FF",
  1388. "nozzle_temp_min": 240,
  1389. "nozzle_temp_max": 280,
  1390. # setting_id intentionally omitted (empty) — as the modal sends it
  1391. },
  1392. )
  1393. assert response.status_code == 200
  1394. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1395. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  1396. assert call_kwargs.kwargs["setting_id"] == "GFSB99"
  1397. @pytest.mark.asyncio
  1398. @pytest.mark.integration
  1399. async def test_configure_generic_material_fallback_derives_setting_id(
  1400. self, async_client: AsyncClient, printer_factory
  1401. ):
  1402. """When only a material is given, the generic tray_info_idx fallback still yields a setting_id (#2604)."""
  1403. printer = await printer_factory(name="X1C")
  1404. mock_client = MagicMock()
  1405. mock_client.ams_set_filament_setting.return_value = True
  1406. mock_client.extrusion_cali_sel.return_value = True
  1407. mock_client.request_status_update.return_value = True
  1408. mock_status = MagicMock()
  1409. mock_status.raw_data = {"ams": {"ams": []}} # No existing tray to reuse
  1410. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1411. mock_pm.get_client.return_value = mock_client
  1412. mock_pm.get_status.return_value = mock_status
  1413. response = await async_client.post(
  1414. f"/api/v1/printers/{printer.id}/slots/0/1/configure",
  1415. params={
  1416. "tray_info_idx": "", # No preset id — route derives generic from material
  1417. "tray_type": "ABS",
  1418. "tray_sub_brands": "Generic ABS",
  1419. "tray_color": "000000FF",
  1420. "nozzle_temp_min": 240,
  1421. "nozzle_temp_max": 280,
  1422. },
  1423. )
  1424. assert response.status_code == 200
  1425. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1426. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  1427. assert call_kwargs.kwargs["setting_id"] == "GFSB99"
  1428. @pytest.mark.asyncio
  1429. @pytest.mark.integration
  1430. async def test_configure_pfus_never_reaches_tray_info_idx(self, async_client: AsyncClient, printer_factory):
  1431. """A PFUS* cloud setting_id is refused as tray_info_idx (#3003).
  1432. The printer's tray_info_idx field is 8 characters. An 18-character PFUS
  1433. is stored truncated and acknowledged as a success -- measured on the A1
  1434. in the #3003 bundle, which sent PFUS9ddc938fe3ab8f and read back
  1435. PFUS9DDC. That leaves the slot holding an id nothing resolves, so the
  1436. slicer shows "Generic" and the calibration table loses the slot too.
  1437. A generic for the material is strictly better, and the preset reference
  1438. survives in setting_id, which does accept a PFUS.
  1439. Reverses the contract #1053 pinned; see the route's own comment.
  1440. """
  1441. printer = await printer_factory(name="H2D")
  1442. mock_client = MagicMock()
  1443. mock_client.ams_set_filament_setting.return_value = True
  1444. mock_client.extrusion_cali_sel.return_value = True
  1445. mock_client.request_status_update.return_value = True
  1446. mock_status = MagicMock()
  1447. mock_status.raw_data = {"ams": {"ams": []}} # No existing tray data
  1448. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1449. mock_pm.get_client.return_value = mock_client
  1450. mock_pm.get_status.return_value = mock_status
  1451. response = await async_client.post(
  1452. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  1453. params={
  1454. "tray_info_idx": "PFUS9ac902733670a9",
  1455. "tray_type": "PLA",
  1456. "tray_sub_brands": "Devil Design PLA",
  1457. "tray_color": "FF0000FF",
  1458. "nozzle_temp_min": 190,
  1459. "nozzle_temp_max": 230,
  1460. },
  1461. )
  1462. assert response.status_code == 200
  1463. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1464. # No tray to reuse -> generic for the material, never the raw PFUS.
  1465. assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
  1466. # The preset reference is not lost: it moves to the field that holds it.
  1467. assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
  1468. @pytest.mark.asyncio
  1469. @pytest.mark.integration
  1470. async def test_configure_pfus_falls_back_to_slot_preset(self, async_client: AsyncClient, printer_factory):
  1471. """With a PFUS refused, the slot's own resolvable preset is reused (#3003).
  1472. The slot already carries P4d64437 -- a local preset id, 8 characters, so
  1473. the printer can actually store it -- for the same material. That beats a
  1474. generic, and it is what the slot's calibration is keyed by.
  1475. """
  1476. printer = await printer_factory(name="H2D")
  1477. mock_client = MagicMock()
  1478. mock_client.ams_set_filament_setting.return_value = True
  1479. mock_client.extrusion_cali_sel.return_value = True
  1480. mock_client.request_status_update.return_value = True
  1481. # Simulate slot already configured by slicer with cloud-synced preset
  1482. mock_status = MagicMock()
  1483. mock_status.raw_data = {
  1484. "ams": {
  1485. "ams": [
  1486. {
  1487. "id": 2,
  1488. "tray": [
  1489. {
  1490. "id": 3,
  1491. "tray_info_idx": "P4d64437",
  1492. "tray_type": "PLA",
  1493. "tray_color": "FF0000FF",
  1494. }
  1495. ],
  1496. }
  1497. ]
  1498. }
  1499. }
  1500. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1501. mock_pm.get_client.return_value = mock_client
  1502. mock_pm.get_status.return_value = mock_status
  1503. response = await async_client.post(
  1504. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  1505. params={
  1506. "tray_info_idx": "PFUS9ac902733670a9",
  1507. "tray_type": "PLA",
  1508. "tray_sub_brands": "Devil Design PLA",
  1509. "tray_color": "FF0000FF",
  1510. "nozzle_temp_min": 190,
  1511. "nozzle_temp_max": 230,
  1512. },
  1513. )
  1514. assert response.status_code == 200
  1515. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1516. # Slot's own storable preset wins over both the PFUS and a generic.
  1517. assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
  1518. assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
  1519. @pytest.mark.asyncio
  1520. @pytest.mark.integration
  1521. async def test_configure_a_silk_preset_does_not_take_a_matte_slot(self, async_client: AsyncClient, printer_factory):
  1522. """Same material, another finish: the slot's Matte id is not reused, and
  1523. the generic is the one for the preset's finish."""
  1524. printer = await printer_factory(name="H2D")
  1525. mock_client = MagicMock()
  1526. mock_client.ams_set_filament_setting.return_value = True
  1527. mock_client.extrusion_cali_sel.return_value = True
  1528. mock_client.request_status_update.return_value = True
  1529. mock_status = MagicMock()
  1530. tray = {"id": 3, "tray_info_idx": "GFA01", "tray_type": "PLA", "tray_sub_brands": "PLA Matte"}
  1531. mock_status.raw_data = {"ams": {"ams": [{"id": 2, "tray": [tray]}]}}
  1532. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1533. mock_pm.get_client.return_value = mock_client
  1534. mock_pm.get_status.return_value = mock_status
  1535. response = await async_client.post(
  1536. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  1537. params={
  1538. "tray_info_idx": "PFUS9ac902733670a9",
  1539. "tray_type": "PLA",
  1540. "tray_sub_brands": "My PLA Silk",
  1541. "tray_color": "FF0000FF",
  1542. "nozzle_temp_min": 190,
  1543. "nozzle_temp_max": 230,
  1544. },
  1545. )
  1546. assert response.status_code == 200
  1547. assert mock_client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL96"
  1548. @pytest.mark.asyncio
  1549. @pytest.mark.integration
  1550. async def test_configure_pfus_generic_when_slot_material_differs(self, async_client: AsyncClient, printer_factory):
  1551. """A slot holding a different material is not reused (#3003).
  1552. Slot has generic PETG, the user is configuring PLA. Neither the refused
  1553. PFUS nor the mismatched slot can supply a filament id, so the generic
  1554. for the requested material does.
  1555. """
  1556. printer = await printer_factory(name="H2D")
  1557. mock_client = MagicMock()
  1558. mock_client.ams_set_filament_setting.return_value = True
  1559. mock_client.extrusion_cali_sel.return_value = True
  1560. mock_client.request_status_update.return_value = True
  1561. # Slot currently has PETG but user is configuring PLA
  1562. mock_status = MagicMock()
  1563. mock_status.raw_data = {
  1564. "ams": {
  1565. "ams": [
  1566. {
  1567. "id": 2,
  1568. "tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
  1569. }
  1570. ]
  1571. }
  1572. }
  1573. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1574. mock_pm.get_client.return_value = mock_client
  1575. mock_pm.get_status.return_value = mock_status
  1576. response = await async_client.post(
  1577. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  1578. params={
  1579. "tray_info_idx": "PFUS9ac902733670a9",
  1580. "tray_type": "PLA",
  1581. "tray_sub_brands": "Devil Design PLA",
  1582. "tray_color": "FF0000FF",
  1583. "nozzle_temp_min": 190,
  1584. "nozzle_temp_max": 230,
  1585. },
  1586. )
  1587. assert response.status_code == 200
  1588. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1589. assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
  1590. assert call_kwargs.kwargs["setting_id"] == "PFUS9ac902733670a9"
  1591. @pytest.mark.asyncio
  1592. @pytest.mark.integration
  1593. async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
  1594. """Empty tray_info_idx (local preset) is replaced with generic."""
  1595. printer = await printer_factory(name="H2D")
  1596. mock_client = MagicMock()
  1597. mock_client.ams_set_filament_setting.return_value = True
  1598. mock_client.extrusion_cali_sel.return_value = True
  1599. mock_client.request_status_update.return_value = True
  1600. mock_status = MagicMock()
  1601. mock_status.raw_data = {"ams": {"ams": []}}
  1602. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1603. mock_pm.get_client.return_value = mock_client
  1604. mock_pm.get_status.return_value = mock_status
  1605. response = await async_client.post(
  1606. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  1607. params={
  1608. "tray_info_idx": "",
  1609. "tray_type": "PETG",
  1610. "tray_sub_brands": "PETG Basic",
  1611. "tray_color": "FFFFFFFF",
  1612. "nozzle_temp_min": 220,
  1613. "nozzle_temp_max": 260,
  1614. },
  1615. )
  1616. assert response.status_code == 200
  1617. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1618. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  1619. @pytest.mark.asyncio
  1620. @pytest.mark.integration
  1621. async def test_configure_pfus_pair_splits_into_generic_and_setting_id(
  1622. self, async_client: AsyncClient, printer_factory
  1623. ):
  1624. """A PFUS sent in BOTH fields is kept only in setting_id (#3003).
  1625. This is the shape the frontend produced when a custom cloud preset's
  1626. detail had filament_id=null, and what #1053 pinned. The A1 measurement
  1627. in #3003 showed where it ends up: the printer truncates tray_info_idx
  1628. to 8 characters, so the slot resolves to nothing and the slicer falls
  1629. back to "Generic" anyway -- the very outcome #1053 set out to avoid,
  1630. plus a broken calibration key. Sending the generic deliberately gets
  1631. the same slicer result honestly and keeps the slot calibratable, and
  1632. setting_id still carries the user's preset.
  1633. """
  1634. printer = await printer_factory(name="H2D")
  1635. mock_client = MagicMock()
  1636. mock_client.ams_set_filament_setting.return_value = True
  1637. mock_client.extrusion_cali_sel.return_value = True
  1638. mock_client.request_status_update.return_value = True
  1639. mock_status = MagicMock()
  1640. mock_status.raw_data = {"ams": {"ams": []}}
  1641. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1642. mock_pm.get_client.return_value = mock_client
  1643. mock_pm.get_status.return_value = mock_status
  1644. response = await async_client.post(
  1645. f"/api/v1/printers/{printer.id}/slots/128/0/configure",
  1646. params={
  1647. "tray_info_idx": "PFUSa8fb76f9733e3c",
  1648. "tray_type": "ABS",
  1649. "tray_sub_brands": "Sting3D ABS",
  1650. "tray_color": "000000FF",
  1651. "nozzle_temp_min": 240,
  1652. "nozzle_temp_max": 280,
  1653. "setting_id": "PFUSa8fb76f9733e3c",
  1654. },
  1655. )
  1656. assert response.status_code == 200
  1657. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1658. assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
  1659. assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
  1660. @pytest.mark.asyncio
  1661. @pytest.mark.integration
  1662. async def test_configure_pfcn_refused_as_tray_info_idx(self, async_client: AsyncClient, printer_factory):
  1663. """PFCN* shared / partner presets are refused the same way (#3003, #1648).
  1664. Same 18-character shape as a PFUS, same truncation. Polymaker's
  1665. "(Custom)" H2D variants are the ones that reach this in the wild.
  1666. """
  1667. printer = await printer_factory(name="H2D")
  1668. mock_client = MagicMock()
  1669. mock_client.ams_set_filament_setting.return_value = True
  1670. mock_client.extrusion_cali_sel.return_value = True
  1671. mock_client.request_status_update.return_value = True
  1672. mock_status = MagicMock()
  1673. mock_status.raw_data = {"ams": {"ams": []}}
  1674. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1675. mock_pm.get_client.return_value = mock_client
  1676. mock_pm.get_status.return_value = mock_status
  1677. response = await async_client.post(
  1678. f"/api/v1/printers/{printer.id}/slots/0/1/configure",
  1679. params={
  1680. "tray_info_idx": "PFCN2a91c7d0e4b118",
  1681. "tray_type": "PETG",
  1682. "tray_sub_brands": "Polymaker PETG (Custom)",
  1683. "tray_color": "0000FFFF",
  1684. "nozzle_temp_min": 220,
  1685. "nozzle_temp_max": 260,
  1686. },
  1687. )
  1688. assert response.status_code == 200
  1689. call_kwargs = mock_client.ams_set_filament_setting.call_args
  1690. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  1691. assert call_kwargs.kwargs["setting_id"] == "PFCN2a91c7d0e4b118"
  1692. class TestSkipObjectsAPI:
  1693. """Integration tests for skip objects endpoints."""
  1694. # ========================================================================
  1695. # Get printable objects endpoint
  1696. # ========================================================================
  1697. @pytest.mark.asyncio
  1698. @pytest.mark.integration
  1699. async def test_get_objects_not_found(self, async_client: AsyncClient):
  1700. """Verify 404 for non-existent printer."""
  1701. response = await async_client.get("/api/v1/printers/99999/print/objects")
  1702. assert response.status_code == 404
  1703. @pytest.mark.asyncio
  1704. @pytest.mark.integration
  1705. async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  1706. """Verify error when printer is not connected."""
  1707. printer = await printer_factory(name="Disconnected Printer")
  1708. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1709. mock_pm.get_client.return_value = None
  1710. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1711. assert response.status_code == 400
  1712. assert "not connected" in response.json()["detail"].lower()
  1713. @pytest.mark.asyncio
  1714. @pytest.mark.integration
  1715. async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):
  1716. """Verify empty objects list when no print is active."""
  1717. printer = await printer_factory(name="Idle Printer")
  1718. mock_client = MagicMock()
  1719. mock_client.state.printable_objects = {}
  1720. mock_client.state.skipped_objects = []
  1721. mock_client.state.state = "IDLE"
  1722. mock_client.state.subtask_name = None # Prevent FTP download attempt
  1723. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1724. mock_pm.get_client.return_value = mock_client
  1725. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1726. assert response.status_code == 200
  1727. result = response.json()
  1728. assert result["objects"] == []
  1729. assert result["total"] == 0
  1730. assert result["skipped_count"] == 0
  1731. assert result["is_printing"] is False
  1732. @pytest.mark.asyncio
  1733. @pytest.mark.integration
  1734. async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):
  1735. """Verify objects list when print is active."""
  1736. printer = await printer_factory(name="Printing Printer")
  1737. mock_client = MagicMock()
  1738. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  1739. mock_client.state.skipped_objects = [200]
  1740. mock_client.state.state = "RUNNING"
  1741. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1742. mock_pm.get_client.return_value = mock_client
  1743. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1744. assert response.status_code == 200
  1745. result = response.json()
  1746. assert result["total"] == 3
  1747. assert result["skipped_count"] == 1
  1748. assert result["is_printing"] is True
  1749. # Check objects have correct structure
  1750. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  1751. assert objects_by_id[100]["name"] == "Part A"
  1752. assert objects_by_id[100]["skipped"] is False
  1753. assert objects_by_id[200]["name"] == "Part B"
  1754. assert objects_by_id[200]["skipped"] is True
  1755. assert objects_by_id[300]["name"] == "Part C"
  1756. assert objects_by_id[300]["skipped"] is False
  1757. # ========================================================================
  1758. # Skip objects endpoint
  1759. # ========================================================================
  1760. @pytest.mark.asyncio
  1761. @pytest.mark.integration
  1762. async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):
  1763. """Verify objects list includes position data when available."""
  1764. printer = await printer_factory(name="Printing Printer")
  1765. # New format with position data
  1766. mock_client = MagicMock()
  1767. mock_client.state.printable_objects = {
  1768. 100: {"name": "Part A", "x": 50.0, "y": 100.0},
  1769. 200: {"name": "Part B", "x": 150.0, "y": 100.0},
  1770. }
  1771. mock_client.state.skipped_objects = []
  1772. mock_client.state.state = "RUNNING"
  1773. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1774. mock_pm.get_client.return_value = mock_client
  1775. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1776. assert response.status_code == 200
  1777. result = response.json()
  1778. assert result["total"] == 2
  1779. # Check objects have position data
  1780. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  1781. assert objects_by_id[100]["name"] == "Part A"
  1782. assert objects_by_id[100]["x"] == 50.0
  1783. assert objects_by_id[100]["y"] == 100.0
  1784. assert objects_by_id[200]["name"] == "Part B"
  1785. assert objects_by_id[200]["x"] == 150.0
  1786. assert objects_by_id[200]["y"] == 100.0
  1787. # ========================================================================
  1788. # Skip objects endpoint
  1789. # ========================================================================
  1790. @pytest.mark.asyncio
  1791. @pytest.mark.integration
  1792. async def test_skip_objects_not_found(self, async_client: AsyncClient):
  1793. """Verify 404 for non-existent printer."""
  1794. response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100])
  1795. assert response.status_code == 404
  1796. @pytest.mark.asyncio
  1797. @pytest.mark.integration
  1798. async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  1799. """Verify error when printer is not connected."""
  1800. printer = await printer_factory(name="Disconnected Printer")
  1801. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1802. mock_pm.get_client.return_value = None
  1803. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1804. assert response.status_code == 400
  1805. assert "not connected" in response.json()["detail"].lower()
  1806. @pytest.mark.asyncio
  1807. @pytest.mark.integration
  1808. async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):
  1809. """Verify error when no object IDs provided."""
  1810. printer = await printer_factory(name="Printing Printer")
  1811. mock_client = MagicMock()
  1812. mock_client.state.printable_objects = {100: "Part A"}
  1813. mock_client.state.skipped_objects = []
  1814. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1815. mock_pm.get_client.return_value = mock_client
  1816. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[])
  1817. assert response.status_code == 400
  1818. assert "no object" in response.json()["detail"].lower()
  1819. @pytest.mark.asyncio
  1820. @pytest.mark.integration
  1821. async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):
  1822. """Verify error when object ID doesn't exist."""
  1823. printer = await printer_factory(name="Printing Printer")
  1824. mock_client = MagicMock()
  1825. mock_client.state.printable_objects = {100: "Part A"}
  1826. mock_client.state.skipped_objects = []
  1827. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1828. mock_pm.get_client.return_value = mock_client
  1829. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999])
  1830. assert response.status_code == 400
  1831. assert "invalid" in response.json()["detail"].lower()
  1832. @pytest.mark.asyncio
  1833. @pytest.mark.integration
  1834. async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):
  1835. """Verify successful skip objects request."""
  1836. printer = await printer_factory(name="Printing Printer")
  1837. mock_client = MagicMock()
  1838. mock_client.state.printable_objects = {100: "Part A", 200: "Part B"}
  1839. mock_client.state.skipped_objects = []
  1840. mock_client.skip_objects.return_value = True
  1841. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1842. mock_pm.get_client.return_value = mock_client
  1843. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1844. assert response.status_code == 200
  1845. result = response.json()
  1846. assert result["success"] is True
  1847. assert 100 in result["skipped_objects"]
  1848. mock_client.skip_objects.assert_called_once_with([100])
  1849. @pytest.mark.asyncio
  1850. @pytest.mark.integration
  1851. async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):
  1852. """Verify skipping multiple objects at once."""
  1853. printer = await printer_factory(name="Printing Printer")
  1854. mock_client = MagicMock()
  1855. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  1856. mock_client.state.skipped_objects = []
  1857. mock_client.skip_objects.return_value = True
  1858. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1859. mock_pm.get_client.return_value = mock_client
  1860. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200])
  1861. assert response.status_code == 200
  1862. result = response.json()
  1863. assert result["success"] is True
  1864. assert 100 in result["skipped_objects"]
  1865. assert 200 in result["skipped_objects"]
  1866. mock_client.skip_objects.assert_called_once_with([100, 200])
  1867. class TestChamberLightAPI:
  1868. """Integration tests for chamber light control endpoint."""
  1869. @pytest.mark.asyncio
  1870. @pytest.mark.integration
  1871. async def test_chamber_light_not_found(self, async_client: AsyncClient):
  1872. """Verify 404 for non-existent printer."""
  1873. response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true")
  1874. assert response.status_code == 404
  1875. @pytest.mark.asyncio
  1876. @pytest.mark.integration
  1877. async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):
  1878. """Verify error when printer is not connected."""
  1879. printer = await printer_factory(name="Disconnected Printer")
  1880. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1881. mock_pm.get_client.return_value = None
  1882. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1883. assert response.status_code == 400
  1884. assert "not connected" in response.json()["detail"].lower()
  1885. @pytest.mark.asyncio
  1886. @pytest.mark.integration
  1887. async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):
  1888. """Verify successful chamber light on request."""
  1889. printer = await printer_factory(name="Test Printer")
  1890. mock_client = MagicMock()
  1891. mock_client.set_chamber_light.return_value = True
  1892. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1893. mock_pm.get_client.return_value = mock_client
  1894. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1895. assert response.status_code == 200
  1896. result = response.json()
  1897. assert result["success"] is True
  1898. assert "on" in result["message"].lower()
  1899. mock_client.set_chamber_light.assert_called_once_with(True)
  1900. @pytest.mark.asyncio
  1901. @pytest.mark.integration
  1902. async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):
  1903. """Verify successful chamber light off request."""
  1904. printer = await printer_factory(name="Test Printer")
  1905. mock_client = MagicMock()
  1906. mock_client.set_chamber_light.return_value = True
  1907. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1908. mock_pm.get_client.return_value = mock_client
  1909. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false")
  1910. assert response.status_code == 200
  1911. result = response.json()
  1912. assert result["success"] is True
  1913. assert "off" in result["message"].lower()
  1914. mock_client.set_chamber_light.assert_called_once_with(False)
  1915. @pytest.mark.asyncio
  1916. @pytest.mark.integration
  1917. async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):
  1918. """Verify error handling when chamber light control fails."""
  1919. printer = await printer_factory(name="Test Printer")
  1920. mock_client = MagicMock()
  1921. mock_client.set_chamber_light.return_value = False
  1922. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1923. mock_pm.get_client.return_value = mock_client
  1924. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1925. assert response.status_code == 500
  1926. assert "failed" in response.json()["detail"].lower()
  1927. class TestAirductModeAPI:
  1928. """Integration tests for the airduct mode endpoint (P2S/H2*)."""
  1929. @pytest.mark.asyncio
  1930. @pytest.mark.integration
  1931. async def test_invalid_mode_rejected(self, async_client: AsyncClient, printer_factory):
  1932. printer = await printer_factory(name="P", model="P2S")
  1933. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=foo")
  1934. assert response.status_code == 400
  1935. @pytest.mark.asyncio
  1936. @pytest.mark.integration
  1937. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  1938. printer = await printer_factory(name="P", model="P2S")
  1939. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1940. mock_pm.get_client.return_value = None
  1941. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1942. assert response.status_code == 400
  1943. @pytest.mark.asyncio
  1944. @pytest.mark.integration
  1945. async def test_cooling_success(self, async_client: AsyncClient, printer_factory):
  1946. printer = await printer_factory(name="P", model="P2S")
  1947. mock_client = MagicMock()
  1948. mock_client.set_airduct_mode.return_value = True
  1949. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1950. mock_pm.get_client.return_value = mock_client
  1951. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1952. assert response.status_code == 200
  1953. assert response.json()["success"] is True
  1954. mock_client.set_airduct_mode.assert_called_once_with("cooling")
  1955. @pytest.mark.asyncio
  1956. @pytest.mark.integration
  1957. async def test_heating_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  1958. printer = await printer_factory(name="P", model="P2S")
  1959. mock_client = MagicMock()
  1960. mock_client.set_airduct_mode.return_value = False
  1961. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1962. mock_pm.get_client.return_value = mock_client
  1963. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=heating")
  1964. assert response.status_code == 500
  1965. class TestClearHMSErrorsAPI:
  1966. """Integration tests for clear HMS errors endpoint."""
  1967. @pytest.mark.asyncio
  1968. @pytest.mark.integration
  1969. async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
  1970. """Verify 404 for non-existent printer."""
  1971. response = await async_client.post("/api/v1/printers/99999/hms/clear")
  1972. assert response.status_code == 404
  1973. @pytest.mark.asyncio
  1974. @pytest.mark.integration
  1975. async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
  1976. """Verify error when printer is not connected."""
  1977. printer = await printer_factory(name="Disconnected Printer")
  1978. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1979. mock_pm.get_client.return_value = None
  1980. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1981. assert response.status_code == 400
  1982. assert "not connected" in response.json()["detail"].lower()
  1983. @pytest.mark.asyncio
  1984. @pytest.mark.integration
  1985. async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
  1986. """Verify successful clear HMS errors request."""
  1987. printer = await printer_factory(name="Test Printer")
  1988. mock_client = MagicMock()
  1989. mock_client.clear_hms_errors.return_value = True
  1990. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1991. mock_pm.get_client.return_value = mock_client
  1992. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1993. assert response.status_code == 200
  1994. result = response.json()
  1995. assert result["success"] is True
  1996. assert "cleared" in result["message"].lower()
  1997. mock_client.clear_hms_errors.assert_called_once()
  1998. @pytest.mark.asyncio
  1999. @pytest.mark.integration
  2000. async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
  2001. """Verify error handling when clear HMS errors fails."""
  2002. printer = await printer_factory(name="Test Printer")
  2003. mock_client = MagicMock()
  2004. mock_client.clear_hms_errors.return_value = False
  2005. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2006. mock_pm.get_client.return_value = mock_client
  2007. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  2008. assert response.status_code == 500
  2009. assert "failed" in response.json()["detail"].lower()
  2010. class TestExecuteHMSActionAPI:
  2011. """Integration tests for the /hms/execute-action endpoint (#1743).
  2012. Mirrors TestClearHMSErrorsAPI's shape — the two routes share the same
  2013. permission gate, the same DB-lookup + client-existence flow, and the
  2014. same dispatch-then-return-success pattern. The body-validation cases
  2015. add coverage that the bare clear endpoint doesn't need.
  2016. """
  2017. _VALID_BODY = {
  2018. "print_error": "03008070",
  2019. "action": "OK_BUTTON",
  2020. "job_id": None,
  2021. }
  2022. @pytest.mark.asyncio
  2023. @pytest.mark.integration
  2024. async def test_execute_hms_action_not_found(self, async_client: AsyncClient):
  2025. """404 for a printer id that doesn't exist."""
  2026. response = await async_client.post("/api/v1/printers/99999/hms/execute-action", json=self._VALID_BODY)
  2027. assert response.status_code == 404
  2028. @pytest.mark.asyncio
  2029. @pytest.mark.integration
  2030. async def test_execute_hms_action_not_connected(self, async_client: AsyncClient, printer_factory):
  2031. """400 when the printer record exists but the MQTT client is offline."""
  2032. printer = await printer_factory(name="Disconnected Printer")
  2033. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2034. mock_pm.get_client.return_value = None
  2035. response = await async_client.post(
  2036. f"/api/v1/printers/{printer.id}/hms/execute-action", json=self._VALID_BODY
  2037. )
  2038. assert response.status_code == 400
  2039. assert "not connected" in response.json()["detail"].lower()
  2040. @pytest.mark.asyncio
  2041. @pytest.mark.integration
  2042. async def test_execute_hms_action_success(self, async_client: AsyncClient, printer_factory):
  2043. """200 happy path — dispatcher returns True AND the printer pushes at
  2044. least one MQTT message into the ack-wait window. A fresh inbound
  2045. message is the firmware's proof that the command landed (publish
  2046. success is necessary but not sufficient; see #1830 §(3))."""
  2047. printer = await printer_factory(name="Test Printer")
  2048. mock_client = MagicMock()
  2049. # Pre-action state — paused with a fault, last message arrived at t=0.
  2050. mock_client.state.state = "PAUSE"
  2051. mock_client.state.print_error = 0x05008051
  2052. mock_client.state.hms_errors = [object()]
  2053. mock_client._last_message_time = 100.0
  2054. def _act(*_a, **_kw):
  2055. # Simulate the printer pushing a status update within the ack-wait
  2056. # window. The pushall that follows every command is what produces
  2057. # this — the actual state fields don't have to move (#1869: a
  2058. # wrong-plate IGNORE_RESUME re-pauses with the same fault but the
  2059. # printer DID push back).
  2060. mock_client._last_message_time = 100.5
  2061. return True
  2062. mock_client.execute_hms_action.side_effect = _act
  2063. with (
  2064. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2065. patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
  2066. ):
  2067. mock_pm.get_client.return_value = mock_client
  2068. body = {"print_error": "07008029", "action": "FILAMENT_EXTRUDED", "job_id": "task-7"}
  2069. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
  2070. assert response.status_code == 200
  2071. result = response.json()
  2072. assert result["success"] is True
  2073. assert "executed" in result["message"].lower()
  2074. # Body args reach the client method in (print_error, action, job_id) order.
  2075. mock_client.execute_hms_action.assert_called_once_with("07008029", "FILAMENT_EXTRUDED", "task-7")
  2076. @pytest.mark.asyncio
  2077. @pytest.mark.integration
  2078. async def test_execute_hms_action_dispatcher_failure(self, async_client: AsyncClient, printer_factory):
  2079. """400 when the dispatcher returns False (unknown action, mid-flight disconnect)."""
  2080. printer = await printer_factory(name="Test Printer")
  2081. mock_client = MagicMock()
  2082. mock_client.execute_hms_action.return_value = False
  2083. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2084. mock_pm.get_client.return_value = mock_client
  2085. response = await async_client.post(
  2086. f"/api/v1/printers/{printer.id}/hms/execute-action", json=self._VALID_BODY
  2087. )
  2088. assert response.status_code == 400
  2089. assert "failed" in response.json()["detail"].lower()
  2090. @pytest.mark.asyncio
  2091. @pytest.mark.integration
  2092. async def test_execute_hms_action_no_printer_ack_returns_502(self, async_client: AsyncClient, printer_factory):
  2093. """502 when publish succeeded but no MQTT message arrives back within
  2094. the ack-wait window. This is the silent-rejection failure mode #1830
  2095. identifies: the broker ACKs the publish at QoS 1 but the firmware
  2096. drops the command (err mismatch, wrong shape, state mismatch).
  2097. Surfacing this as 502 instead of 200 stops the UI from claiming
  2098. success while the modal sticks."""
  2099. printer = await printer_factory(name="Test Printer")
  2100. mock_client = MagicMock()
  2101. mock_client.state.state = "PAUSE"
  2102. mock_client.state.print_error = 0x05008051
  2103. mock_client.state.hms_errors = [object()]
  2104. mock_client._last_message_time = 100.0
  2105. mock_client.execute_hms_action.return_value = True # publish "succeeded"
  2106. # Crucially: _last_message_time does NOT advance → no inbound push.
  2107. with (
  2108. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2109. patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
  2110. ):
  2111. mock_pm.get_client.return_value = mock_client
  2112. response = await async_client.post(
  2113. f"/api/v1/printers/{printer.id}/hms/execute-action", json=self._VALID_BODY
  2114. )
  2115. assert response.status_code == 502
  2116. assert "acknowledge" in response.json()["detail"].lower()
  2117. @pytest.mark.asyncio
  2118. @pytest.mark.integration
  2119. async def test_execute_hms_action_ignore_resume_repauses_within_window_still_acks(
  2120. self, async_client: AsyncClient, printer_factory
  2121. ):
  2122. """200 when the printer ack'd the command but immediately re-paused
  2123. with the same fault — e.g. wrong-plate IGNORE_RESUME (#1869). The
  2124. previous (gcode_state, hms_errors-len) diff produced a false 502
  2125. because both fields round-tripped to their pre-publish values inside
  2126. the ack window. Probing `_last_message_time` survives the round-trip
  2127. because the printer's status push lands regardless of the eventual
  2128. state."""
  2129. printer = await printer_factory(name="Test Printer")
  2130. mock_client = MagicMock()
  2131. mock_client.state.state = "PAUSE"
  2132. mock_client.state.print_error = 0x05008051
  2133. mock_client.state.hms_errors = [object()]
  2134. mock_client._last_message_time = 100.0
  2135. def _act(*_a, **_kw):
  2136. # Printer ack'd, briefly resumed, re-detected the wrong plate, and
  2137. # re-paused with the same fault. Net diff on state fields is zero,
  2138. # but a fresh status push DID arrive.
  2139. mock_client._last_message_time = 100.4
  2140. mock_client.state.state = "PAUSE" # round-tripped
  2141. mock_client.state.hms_errors = [object()] # same length
  2142. return True
  2143. mock_client.execute_hms_action.side_effect = _act
  2144. with (
  2145. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2146. patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
  2147. ):
  2148. mock_pm.get_client.return_value = mock_client
  2149. body = {"print_error": "05008051", "action": "IGNORE_RESUME", "job_id": None}
  2150. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
  2151. assert response.status_code == 200
  2152. assert response.json()["success"] is True
  2153. @pytest.mark.asyncio
  2154. @pytest.mark.integration
  2155. async def test_execute_hms_action_accepts_16_char_full_code(self, async_client: AsyncClient, printer_factory):
  2156. """200 for a 16-char full_code (hms[]-array-sourced fault). The
  2157. schema's relaxed pattern allows both 8-char (print_error) and
  2158. 16-char (hms[]) shapes."""
  2159. printer = await printer_factory(name="Test Printer")
  2160. mock_client = MagicMock()
  2161. mock_client.state.state = "RUNNING"
  2162. mock_client.state.print_error = 0
  2163. mock_client.state.hms_errors = [object()]
  2164. mock_client._last_message_time = 100.0
  2165. def _act(*_a, **_kw):
  2166. mock_client._last_message_time = 100.4
  2167. return True
  2168. mock_client.execute_hms_action.side_effect = _act
  2169. with (
  2170. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2171. patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
  2172. ):
  2173. mock_pm.get_client.return_value = mock_client
  2174. body = {"print_error": "0C00030000020010", "action": "IGNORE_RESUME"}
  2175. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
  2176. assert response.status_code == 200
  2177. mock_client.execute_hms_action.assert_called_once_with("0C00030000020010", "IGNORE_RESUME", None)
  2178. @pytest.mark.asyncio
  2179. @pytest.mark.integration
  2180. async def test_execute_hms_action_rejects_malformed_print_error(self, async_client: AsyncClient, printer_factory):
  2181. """422 when print_error fails the relaxed pattern (8 OR 16 hex chars).
  2182. Lengths in between (9-15) and outside (7, 17+) are invalid; stray
  2183. input can't reach the dispatcher's match statement."""
  2184. printer = await printer_factory(name="Test Printer")
  2185. bad_bodies = [
  2186. {"print_error": "0300_8070", "action": "OK_BUTTON"}, # underscore
  2187. {"print_error": "0300807", "action": "OK_BUTTON"}, # 7 chars (too short)
  2188. {"print_error": "030080700", "action": "OK_BUTTON"}, # 9 chars (between)
  2189. {"print_error": "030080700300807", "action": "OK_BUTTON"}, # 15 chars (between)
  2190. {"print_error": "0300807003008070A", "action": "OK_BUTTON"}, # 17 chars (too long)
  2191. {"print_error": "0300GGGG", "action": "OK_BUTTON"}, # non-hex
  2192. ]
  2193. for body in bad_bodies:
  2194. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
  2195. assert response.status_code == 422, body
  2196. def _build_h2d_state(*, ams_id: int = 0, tray_id: int = 2, cali_idx: int = 5):
  2197. """Build a MagicMock PrinterState for an H2D printer with a single BL spool tray.
  2198. Used by both TestApplyPaAfterRefresh (Phase 13 P13-T-BE-1) and the K-profile
  2199. persistence tests below. The tray data passes is_bambu_tag (32-char non-zero
  2200. tray_uuid + non-empty tray_info_idx).
  2201. """
  2202. nozzle = MagicMock(nozzle_diameter="0.4")
  2203. state = MagicMock()
  2204. state.nozzles = [nozzle]
  2205. state.ams_extruder_map = {"0": 0}
  2206. state.raw_data = {
  2207. "ams": [
  2208. {
  2209. "id": ams_id,
  2210. "tray": [
  2211. {
  2212. "id": tray_id,
  2213. "tray_type": "PLA",
  2214. "tag_uid": "AABBCC1122334400",
  2215. "tray_uuid": "11223344556677880011223344556677",
  2216. "tray_info_idx": "GFL05",
  2217. "cali_idx": cali_idx,
  2218. }
  2219. ],
  2220. }
  2221. ]
  2222. }
  2223. return state
  2224. def _patch_async_session_to(db_session):
  2225. """Patch backend.app.core.database.async_session so calls inside the function
  2226. under test reuse the test fixture's db_session.
  2227. `_apply_pa_after_refresh` lazy-imports `from backend.app.core.database import
  2228. async_session` at runtime (line 2849). When we patch the source module
  2229. before the call, the lazy import picks up the patched object.
  2230. Returns the patch context manager; use as `with _patch_async_session_to(db_session):`.
  2231. Pattern verified against test_print_lifecycle.py:38-42.
  2232. """
  2233. cm = AsyncMock()
  2234. cm.__aenter__ = AsyncMock(return_value=db_session)
  2235. cm.__aexit__ = AsyncMock(return_value=None)
  2236. return patch("backend.app.core.database.async_session", return_value=cm)
  2237. class TestApplyPaAfterRefresh:
  2238. """Phase 13 P13-T-BE-1: _apply_pa_after_refresh K-profile cascade.
  2239. Verifies the 3-stage cascade (local SpoolKProfile → Spoolman SpoolmanKProfile
  2240. → live tray.cali_idx fallback) and the Bug A regression (kp.extruder, not
  2241. kp.extruder_id, after the Phase 13 P13-2a fix).
  2242. `_apply_pa_after_refresh` is a free function spawned via asyncio.create_task
  2243. from the /ams-refresh endpoint. Tests call it directly because awaiting the
  2244. spawned task in an HTTP test would require sleeping past the 5-second guard
  2245. that delays MQTT until RFID re-read finishes.
  2246. """
  2247. @pytest.mark.asyncio
  2248. @pytest.mark.integration
  2249. async def test_local_kp_match_sends_stored_cali_idx(self, db_session, printer_factory):
  2250. """Local SpoolAssignment + matching SpoolKProfile → stored cali_idx wins."""
  2251. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2252. from backend.app.models.spool import Spool
  2253. from backend.app.models.spool_assignment import SpoolAssignment
  2254. from backend.app.models.spool_k_profile import SpoolKProfile
  2255. printer = await printer_factory()
  2256. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  2257. db_session.add(spool)
  2258. await db_session.flush()
  2259. db_session.add(
  2260. SpoolAssignment(
  2261. spool_id=spool.id,
  2262. printer_id=printer.id,
  2263. ams_id=0,
  2264. tray_id=2,
  2265. )
  2266. )
  2267. db_session.add(
  2268. SpoolKProfile(
  2269. spool_id=spool.id,
  2270. printer_id=printer.id,
  2271. extruder=0,
  2272. nozzle_diameter="0.4",
  2273. k_value=0.025,
  2274. cali_idx=42,
  2275. )
  2276. )
  2277. await db_session.commit()
  2278. mock_client = MagicMock()
  2279. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2280. state = _build_h2d_state(cali_idx=5)
  2281. with (
  2282. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2283. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2284. _patch_async_session_to(db_session),
  2285. ):
  2286. mock_pm.get_client.return_value = mock_client
  2287. mock_pm.get_status.return_value = state
  2288. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2289. mock_client.extrusion_cali_sel.assert_called_once()
  2290. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  2291. assert kwargs["cali_idx"] == 42 # stored profile, not 5 (live)
  2292. @pytest.mark.asyncio
  2293. @pytest.mark.integration
  2294. async def test_local_no_kp_uses_live_cali_idx(self, db_session, printer_factory):
  2295. """Local SpoolAssignment but no matching SpoolKProfile → live cali_idx (Stage 3)."""
  2296. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2297. from backend.app.models.spool import Spool
  2298. from backend.app.models.spool_assignment import SpoolAssignment
  2299. printer = await printer_factory()
  2300. spool = Spool(material="PLA")
  2301. db_session.add(spool)
  2302. await db_session.flush()
  2303. db_session.add(
  2304. SpoolAssignment(
  2305. spool_id=spool.id,
  2306. printer_id=printer.id,
  2307. ams_id=0,
  2308. tray_id=2,
  2309. )
  2310. )
  2311. await db_session.commit()
  2312. mock_client = MagicMock()
  2313. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2314. state = _build_h2d_state(cali_idx=5)
  2315. with (
  2316. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2317. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2318. _patch_async_session_to(db_session),
  2319. ):
  2320. mock_pm.get_client.return_value = mock_client
  2321. mock_pm.get_status.return_value = state
  2322. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2323. mock_client.extrusion_cali_sel.assert_called_once()
  2324. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  2325. assert kwargs["cali_idx"] == 5 # live tray.cali_idx fallback
  2326. @pytest.mark.asyncio
  2327. @pytest.mark.integration
  2328. async def test_spoolman_kp_when_no_local(self, db_session, printer_factory):
  2329. """No local assignment + Spoolman SlotAssignment + SpoolmanKProfile → Spoolman cali_idx."""
  2330. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2331. from backend.app.models.settings import Settings
  2332. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2333. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2334. # A spoolman_slot_assignments row only exists in Spoolman mode, and
  2335. # since #2812 the mode is asked for explicitly rather than inferred
  2336. # from which table happens to hold rows.
  2337. db_session.add(Settings(key="spoolman_enabled", value="true"))
  2338. printer = await printer_factory()
  2339. db_session.add(
  2340. SpoolmanSlotAssignment(
  2341. printer_id=printer.id,
  2342. ams_id=0,
  2343. tray_id=2,
  2344. spoolman_spool_id=99,
  2345. )
  2346. )
  2347. db_session.add(
  2348. SpoolmanKProfile(
  2349. spoolman_spool_id=99,
  2350. printer_id=printer.id,
  2351. extruder=0,
  2352. nozzle_diameter="0.4",
  2353. k_value=0.030,
  2354. cali_idx=77,
  2355. )
  2356. )
  2357. await db_session.commit()
  2358. mock_client = MagicMock()
  2359. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2360. state = _build_h2d_state(cali_idx=5)
  2361. with (
  2362. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2363. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2364. _patch_async_session_to(db_session),
  2365. ):
  2366. mock_pm.get_client.return_value = mock_client
  2367. mock_pm.get_status.return_value = state
  2368. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2369. mock_client.extrusion_cali_sel.assert_called_once()
  2370. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  2371. assert kwargs["cali_idx"] == 77 # Spoolman stored profile
  2372. @pytest.mark.asyncio
  2373. @pytest.mark.integration
  2374. async def test_spoolman_no_kp_uses_live(self, db_session, printer_factory):
  2375. """Spoolman SlotAssignment but no SpoolmanKProfile → live cali_idx (Stage 3)."""
  2376. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2377. from backend.app.models.settings import Settings
  2378. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2379. # A spoolman_slot_assignments row only exists in Spoolman mode, and
  2380. # since #2812 the mode is asked for explicitly rather than inferred
  2381. # from which table happens to hold rows.
  2382. db_session.add(Settings(key="spoolman_enabled", value="true"))
  2383. printer = await printer_factory()
  2384. db_session.add(
  2385. SpoolmanSlotAssignment(
  2386. printer_id=printer.id,
  2387. ams_id=0,
  2388. tray_id=2,
  2389. spoolman_spool_id=99,
  2390. )
  2391. )
  2392. await db_session.commit()
  2393. mock_client = MagicMock()
  2394. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2395. state = _build_h2d_state(cali_idx=5)
  2396. with (
  2397. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2398. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2399. _patch_async_session_to(db_session),
  2400. ):
  2401. mock_pm.get_client.return_value = mock_client
  2402. mock_pm.get_status.return_value = state
  2403. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2404. mock_client.extrusion_cali_sel.assert_called_once()
  2405. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  2406. @pytest.mark.asyncio
  2407. @pytest.mark.integration
  2408. async def test_no_assignment_uses_live(self, db_session, printer_factory):
  2409. """No assignment of any kind + live cali_idx >= 0 → live fallback."""
  2410. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2411. printer = await printer_factory()
  2412. mock_client = MagicMock()
  2413. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2414. state = _build_h2d_state(cali_idx=5)
  2415. with (
  2416. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2417. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2418. _patch_async_session_to(db_session),
  2419. ):
  2420. mock_pm.get_client.return_value = mock_client
  2421. mock_pm.get_status.return_value = state
  2422. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2423. mock_client.extrusion_cali_sel.assert_called_once()
  2424. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  2425. @pytest.mark.asyncio
  2426. @pytest.mark.integration
  2427. async def test_negative_live_cali_idx_skipped(self, db_session, printer_factory):
  2428. """No assignment + live cali_idx=-1 → no MQTT call (invalid live value)."""
  2429. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2430. printer = await printer_factory()
  2431. mock_client = MagicMock()
  2432. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2433. state = _build_h2d_state(cali_idx=-1)
  2434. with (
  2435. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2436. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2437. _patch_async_session_to(db_session),
  2438. ):
  2439. mock_pm.get_client.return_value = mock_client
  2440. mock_pm.get_status.return_value = state
  2441. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2442. mock_client.extrusion_cali_sel.assert_not_called()
  2443. @pytest.mark.asyncio
  2444. @pytest.mark.integration
  2445. async def test_no_assignment_no_live_cali_idx_no_call(self, db_session, printer_factory):
  2446. """No assignment of any kind AND no live cali_idx in tray → no MQTT call.
  2447. Distinct from test_negative_live_cali_idx_skipped: that test has
  2448. cali_idx=-1 in raw_data; this one omits the field entirely (returns
  2449. None from .get("cali_idx")). Both must result in no MQTT call.
  2450. """
  2451. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2452. printer = await printer_factory()
  2453. mock_client = MagicMock()
  2454. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2455. # State with NO cali_idx field on tray at all
  2456. nozzle = MagicMock(nozzle_diameter="0.4")
  2457. state = MagicMock()
  2458. state.nozzles = [nozzle]
  2459. state.ams_extruder_map = {"0": 0}
  2460. state.raw_data = {
  2461. "ams": [
  2462. {
  2463. "id": 0,
  2464. "tray": [
  2465. {
  2466. "id": 2,
  2467. "tray_type": "PLA",
  2468. "tag_uid": "AABBCC1122334400",
  2469. "tray_uuid": "11223344556677880011223344556677",
  2470. "tray_info_idx": "GFL05",
  2471. # cali_idx field intentionally omitted
  2472. }
  2473. ],
  2474. }
  2475. ]
  2476. }
  2477. with (
  2478. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2479. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2480. _patch_async_session_to(db_session),
  2481. ):
  2482. mock_pm.get_client.return_value = mock_client
  2483. mock_pm.get_status.return_value = state
  2484. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2485. mock_client.extrusion_cali_sel.assert_not_called()
  2486. @pytest.mark.asyncio
  2487. @pytest.mark.integration
  2488. async def test_extruder_mismatch_uses_kp_as_fallback(self, db_session, printer_factory):
  2489. """K-profile for extruder=1 but slot is extruder=0 → no exact match,
  2490. but the kp is used as extruder-agnostic fallback rather than dropped.
  2491. Hard-skipping on extruder mismatch was the previous behavior; in
  2492. practice it caused stored K-profiles to be silently ignored whenever
  2493. the AMS-extruder mapping had shifted (or when only one of the two
  2494. extruders was ever calibrated for a given spool). The cascade now
  2495. prefers an exact extruder match but falls back to any matching kp
  2496. for the same printer + nozzle.
  2497. """
  2498. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2499. from backend.app.models.spool import Spool
  2500. from backend.app.models.spool_assignment import SpoolAssignment
  2501. from backend.app.models.spool_k_profile import SpoolKProfile
  2502. printer = await printer_factory()
  2503. spool = Spool(material="PLA")
  2504. db_session.add(spool)
  2505. await db_session.flush()
  2506. db_session.add(
  2507. SpoolAssignment(
  2508. spool_id=spool.id,
  2509. printer_id=printer.id,
  2510. ams_id=0,
  2511. tray_id=2,
  2512. )
  2513. )
  2514. # K-profile is for extruder=1, but slot's ams_extruder_map["0"]=0
  2515. db_session.add(
  2516. SpoolKProfile(
  2517. spool_id=spool.id,
  2518. printer_id=printer.id,
  2519. extruder=1,
  2520. nozzle_diameter="0.4",
  2521. k_value=0.025,
  2522. cali_idx=42,
  2523. )
  2524. )
  2525. await db_session.commit()
  2526. mock_client = MagicMock()
  2527. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2528. state = _build_h2d_state(cali_idx=5)
  2529. with (
  2530. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2531. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2532. _patch_async_session_to(db_session),
  2533. ):
  2534. mock_pm.get_client.return_value = mock_client
  2535. mock_pm.get_status.return_value = state
  2536. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2537. # No exact extruder match, but the stored kp wins as the
  2538. # extruder-agnostic fallback over live cali_idx=5.
  2539. mock_client.extrusion_cali_sel.assert_called_once()
  2540. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  2541. @pytest.mark.asyncio
  2542. @pytest.mark.integration
  2543. async def test_extruder_exact_match_preferred_over_fallback(
  2544. self,
  2545. db_session,
  2546. printer_factory,
  2547. ):
  2548. """When two kp rows exist, one with matching extruder and one without,
  2549. the exact-extruder kp wins (extruder-agnostic fallback only fires when
  2550. no exact match exists).
  2551. """
  2552. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2553. from backend.app.models.spool import Spool
  2554. from backend.app.models.spool_assignment import SpoolAssignment
  2555. from backend.app.models.spool_k_profile import SpoolKProfile
  2556. printer = await printer_factory()
  2557. spool = Spool(material="PLA")
  2558. db_session.add(spool)
  2559. await db_session.flush()
  2560. db_session.add(
  2561. SpoolAssignment(
  2562. spool_id=spool.id,
  2563. printer_id=printer.id,
  2564. ams_id=0,
  2565. tray_id=2,
  2566. )
  2567. )
  2568. # Two kp rows: extruder=1 (mismatch w/ slot extruder=0) and extruder=0 (exact)
  2569. db_session.add(
  2570. SpoolKProfile(
  2571. spool_id=spool.id,
  2572. printer_id=printer.id,
  2573. extruder=1,
  2574. nozzle_diameter="0.4",
  2575. k_value=0.030,
  2576. cali_idx=99,
  2577. )
  2578. )
  2579. db_session.add(
  2580. SpoolKProfile(
  2581. spool_id=spool.id,
  2582. printer_id=printer.id,
  2583. extruder=0,
  2584. nozzle_diameter="0.4",
  2585. k_value=0.025,
  2586. cali_idx=42,
  2587. )
  2588. )
  2589. await db_session.commit()
  2590. mock_client = MagicMock()
  2591. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2592. state = _build_h2d_state(cali_idx=5)
  2593. with (
  2594. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2595. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2596. _patch_async_session_to(db_session),
  2597. ):
  2598. mock_pm.get_client.return_value = mock_client
  2599. mock_pm.get_status.return_value = state
  2600. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2601. # Exact-extruder=0 kp wins (cali_idx=42), not the extruder=1 fallback (99)
  2602. mock_client.extrusion_cali_sel.assert_called_once()
  2603. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  2604. @pytest.mark.asyncio
  2605. @pytest.mark.integration
  2606. async def test_regression_bug_a_kp_extruder_attr(self, db_session, printer_factory):
  2607. """Regression test for Phase 13 P13-2a Bug A.
  2608. Pre-fix Z.2910 used `kp.extruder_id` (AttributeError on SpoolKProfile,
  2609. silently swallowed by outer try/except). On dual-nozzle printers with
  2610. slot_extruder != None this caused the K-profile match loop to crash.
  2611. After P13-2a the field name is correct: `kp.extruder`.
  2612. """
  2613. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2614. from backend.app.models.spool import Spool
  2615. from backend.app.models.spool_assignment import SpoolAssignment
  2616. from backend.app.models.spool_k_profile import SpoolKProfile
  2617. printer = await printer_factory()
  2618. spool = Spool(material="PLA")
  2619. db_session.add(spool)
  2620. await db_session.flush()
  2621. db_session.add(
  2622. SpoolAssignment(
  2623. spool_id=spool.id,
  2624. printer_id=printer.id,
  2625. ams_id=0,
  2626. tray_id=2,
  2627. )
  2628. )
  2629. # extruder=0 matches slot_extruder=0 (from ams_extruder_map={"0":0})
  2630. db_session.add(
  2631. SpoolKProfile(
  2632. spool_id=spool.id,
  2633. printer_id=printer.id,
  2634. extruder=0,
  2635. nozzle_diameter="0.4",
  2636. k_value=0.025,
  2637. cali_idx=42,
  2638. )
  2639. )
  2640. await db_session.commit()
  2641. mock_client = MagicMock()
  2642. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2643. state = _build_h2d_state(cali_idx=5)
  2644. with (
  2645. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2646. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2647. _patch_async_session_to(db_session),
  2648. ):
  2649. mock_pm.get_client.return_value = mock_client
  2650. mock_pm.get_status.return_value = state
  2651. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2652. # If Bug A regressed (kp.extruder_id), the loop would AttributeError → silent fail
  2653. # → no extrusion_cali_sel call. Post-fix the loop matches and sends cali_idx=42.
  2654. mock_client.extrusion_cali_sel.assert_called_once()
  2655. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  2656. @pytest.mark.asyncio
  2657. @pytest.mark.integration
  2658. async def test_tag_fallback_finds_spool_when_assignment_missing(
  2659. self,
  2660. db_session,
  2661. printer_factory,
  2662. ):
  2663. """Stage 1b regression for the maintainer's #2 reproducer on H2D:
  2664. reset slot, trigger re-read → slot ends up on the default K-profile
  2665. instead of the spool's stored profile.
  2666. Setup mirrors the bug:
  2667. - Spool has tray_uuid set (the RFID tag was registered earlier).
  2668. - SpoolKProfile exists for that spool with cali_idx=42.
  2669. - NO SpoolAssignment row — the reset deleted it before the re-read
  2670. triggered _apply_pa_after_refresh, and tag-auto-detect has not
  2671. re-created it yet within the 5 s sleep window.
  2672. - Live tray.cali_idx=5 (firmware-default after the RFID re-read).
  2673. Without Stage 1b the cascade falls through to Stage 3 and re-asserts
  2674. the firmware-default cali_idx=5. With Stage 1b it locates the spool by
  2675. the live tray's tray_uuid and applies the stored cali_idx=42.
  2676. """
  2677. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2678. from backend.app.models.spool import Spool
  2679. from backend.app.models.spool_k_profile import SpoolKProfile
  2680. printer = await printer_factory()
  2681. # Spool with tray_uuid matching the one _build_h2d_state puts on the tray
  2682. spool = Spool(
  2683. material="PLA",
  2684. color_name="Red",
  2685. rgba="FF0000FF",
  2686. tray_uuid="11223344556677880011223344556677",
  2687. tag_uid="AABBCC1122334400",
  2688. )
  2689. db_session.add(spool)
  2690. await db_session.flush()
  2691. # K-profile is bound to the spool, not to a slot
  2692. db_session.add(
  2693. SpoolKProfile(
  2694. spool_id=spool.id,
  2695. printer_id=printer.id,
  2696. extruder=0,
  2697. nozzle_diameter="0.4",
  2698. k_value=0.025,
  2699. cali_idx=42,
  2700. )
  2701. )
  2702. # NOTE: deliberately no SpoolAssignment — that's the bug condition.
  2703. await db_session.commit()
  2704. mock_client = MagicMock()
  2705. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2706. state = _build_h2d_state(cali_idx=5)
  2707. with (
  2708. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2709. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2710. _patch_async_session_to(db_session),
  2711. ):
  2712. mock_pm.get_client.return_value = mock_client
  2713. mock_pm.get_status.return_value = state
  2714. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2715. # Stage 1b should match the spool by tray_uuid → stored cali_idx=42 wins
  2716. # over live cali_idx=5. Pre-fix this would have been 5 (firmware default).
  2717. mock_client.extrusion_cali_sel.assert_called_once()
  2718. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  2719. @pytest.mark.asyncio
  2720. @pytest.mark.integration
  2721. async def test_tag_fallback_matches_by_tag_uid_when_uuid_zero(
  2722. self,
  2723. db_session,
  2724. printer_factory,
  2725. ):
  2726. """Stage 1b: when tray_uuid is the zero sentinel but tag_uid is real,
  2727. match by tag_uid. Older firmwares occasionally report a zero tray_uuid
  2728. right after RFID re-read while the tag_uid is already populated."""
  2729. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2730. from backend.app.models.spool import Spool
  2731. from backend.app.models.spool_k_profile import SpoolKProfile
  2732. printer = await printer_factory()
  2733. # Spool indexed by tag_uid, not tray_uuid
  2734. spool = Spool(material="PLA", tag_uid="AABBCC1122334400")
  2735. db_session.add(spool)
  2736. await db_session.flush()
  2737. db_session.add(
  2738. SpoolKProfile(
  2739. spool_id=spool.id,
  2740. printer_id=printer.id,
  2741. extruder=0,
  2742. nozzle_diameter="0.4",
  2743. k_value=0.025,
  2744. cali_idx=99,
  2745. )
  2746. )
  2747. await db_session.commit()
  2748. mock_client = MagicMock()
  2749. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2750. # Build a state where the tray reports a real tag_uid but a zero tray_uuid
  2751. # while still passing is_bambu_tag (tag_uid + tray_info_idx is sufficient).
  2752. state = _build_h2d_state(cali_idx=5)
  2753. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  2754. with (
  2755. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2756. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2757. _patch_async_session_to(db_session),
  2758. ):
  2759. mock_pm.get_client.return_value = mock_client
  2760. mock_pm.get_status.return_value = state
  2761. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2762. mock_client.extrusion_cali_sel.assert_called_once()
  2763. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 99
  2764. @pytest.mark.asyncio
  2765. @pytest.mark.integration
  2766. async def test_tag_fallback_skipped_when_zero_sentinels(
  2767. self,
  2768. db_session,
  2769. printer_factory,
  2770. ):
  2771. """Stage 1b: when both tray_uuid and tag_uid are zero sentinels, the
  2772. fallback must not match any spool (would otherwise pick up an
  2773. unrelated spool created with empty/zero tag fields). Falls through
  2774. to Stage 3 live cali_idx as before.
  2775. """
  2776. from backend.app.api.routes.printers import _apply_pa_after_refresh
  2777. from backend.app.models.spool import Spool
  2778. from backend.app.models.spool_k_profile import SpoolKProfile
  2779. printer = await printer_factory()
  2780. # Decoy spool with no tag info — must NOT match
  2781. spool = Spool(material="PLA")
  2782. db_session.add(spool)
  2783. await db_session.flush()
  2784. db_session.add(
  2785. SpoolKProfile(
  2786. spool_id=spool.id,
  2787. printer_id=printer.id,
  2788. extruder=0,
  2789. nozzle_diameter="0.4",
  2790. k_value=0.025,
  2791. cali_idx=42,
  2792. )
  2793. )
  2794. await db_session.commit()
  2795. mock_client = MagicMock()
  2796. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  2797. state = _build_h2d_state(cali_idx=7)
  2798. # Force both tag fields to the zero sentinels but keep tray_info_idx
  2799. # so is_bambu_tag still passes (preset present)
  2800. state.raw_data["ams"][0]["tray"][0]["tag_uid"] = "0000000000000000"
  2801. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  2802. with (
  2803. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  2804. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2805. _patch_async_session_to(db_session),
  2806. ):
  2807. mock_pm.get_client.return_value = mock_client
  2808. mock_pm.get_status.return_value = state
  2809. # is_bambu_tag actually rejects both-zero + only-preset, so the
  2810. # function returns early. We just want to confirm we didn't blow
  2811. # up scanning for a tag-fallback spool.
  2812. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  2813. # is_bambu_tag short-circuits early when both UID and UUID are zero,
  2814. # so no MQTT call should fire and the decoy spool's cali_idx=42 must
  2815. # NOT leak through.
  2816. if mock_client.extrusion_cali_sel.called:
  2817. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] != 42
  2818. class TestConfigureAmsSlotPersistsKProfile:
  2819. """Phase 13 P13-T-BE-2: configure_ams_slot persists K-profile to DB.
  2820. Pre-Phase-13 the endpoint sent extrusion_cali_sel via MQTT but never
  2821. recorded the choice in spool_k_profile / spoolman_k_profile, so the next
  2822. RFID re-read had no stored profile to apply.
  2823. """
  2824. @pytest.mark.asyncio
  2825. @pytest.mark.integration
  2826. async def test_writes_spoolman_kprofile_when_spoolman_assigned(
  2827. self,
  2828. async_client: AsyncClient,
  2829. db_session,
  2830. printer_factory,
  2831. ):
  2832. """SpoolmanSlotAssignment present → SpoolmanKProfile row created with cali_idx + k_value + name."""
  2833. from backend.app.models.settings import Settings
  2834. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2835. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2836. # A spoolman_slot_assignments row only exists in Spoolman mode, and
  2837. # since #2812 the mode is asked for explicitly rather than inferred
  2838. # from which table happens to hold rows.
  2839. db_session.add(Settings(key="spoolman_enabled", value="true"))
  2840. printer = await printer_factory(model="H2D")
  2841. db_session.add(
  2842. SpoolmanSlotAssignment(
  2843. printer_id=printer.id,
  2844. ams_id=0,
  2845. tray_id=3,
  2846. spoolman_spool_id=216,
  2847. )
  2848. )
  2849. await db_session.commit()
  2850. mock_client = MagicMock()
  2851. mock_client.ams_set_filament_setting.return_value = True
  2852. mock_client.extrusion_cali_sel.return_value = True
  2853. mock_client.extrusion_cali_set.return_value = True
  2854. mock_client.request_status_update.return_value = True
  2855. mock_state = MagicMock()
  2856. mock_state.ams_extruder_map = {"0": 0}
  2857. mock_state.raw_data = {"ams": {"ams": []}}
  2858. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2859. mock_pm.get_client.return_value = mock_client
  2860. mock_pm.get_status.return_value = mock_state
  2861. response = await async_client.post(
  2862. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2863. params={
  2864. "tray_info_idx": "GFL05",
  2865. "tray_type": "PLA",
  2866. "tray_sub_brands": "Bambu PLA Metal",
  2867. "tray_color": "FF8800FF",
  2868. "nozzle_temp_min": 220,
  2869. "nozzle_temp_max": 240,
  2870. "cali_idx": 5,
  2871. "nozzle_diameter": "0.4",
  2872. "k_value": 0.022,
  2873. },
  2874. )
  2875. assert response.status_code == 200
  2876. kp_result = await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  2877. kp = kp_result.scalar_one_or_none()
  2878. assert kp is not None
  2879. assert kp.cali_idx == 5
  2880. assert kp.k_value == pytest.approx(0.022)
  2881. assert kp.extruder == 0
  2882. assert kp.nozzle_diameter == "0.4"
  2883. assert kp.name == "Bambu PLA Metal"
  2884. @pytest.mark.asyncio
  2885. @pytest.mark.integration
  2886. async def test_writes_spool_kprofile_when_local_assigned(
  2887. self,
  2888. async_client: AsyncClient,
  2889. db_session,
  2890. printer_factory,
  2891. ):
  2892. """Local SpoolAssignment present → SpoolKProfile row created."""
  2893. from backend.app.models.spool import Spool
  2894. from backend.app.models.spool_assignment import SpoolAssignment
  2895. from backend.app.models.spool_k_profile import SpoolKProfile
  2896. printer = await printer_factory(model="H2D")
  2897. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  2898. db_session.add(spool)
  2899. await db_session.flush()
  2900. db_session.add(
  2901. SpoolAssignment(
  2902. spool_id=spool.id,
  2903. printer_id=printer.id,
  2904. ams_id=0,
  2905. tray_id=3,
  2906. )
  2907. )
  2908. await db_session.commit()
  2909. spool_id = spool.id
  2910. mock_client = MagicMock()
  2911. mock_client.ams_set_filament_setting.return_value = True
  2912. mock_client.extrusion_cali_sel.return_value = True
  2913. mock_client.extrusion_cali_set.return_value = True
  2914. mock_client.request_status_update.return_value = True
  2915. mock_state = MagicMock()
  2916. mock_state.ams_extruder_map = {"0": 0}
  2917. mock_state.raw_data = {"ams": {"ams": []}}
  2918. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2919. mock_pm.get_client.return_value = mock_client
  2920. mock_pm.get_status.return_value = mock_state
  2921. response = await async_client.post(
  2922. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2923. params={
  2924. "tray_info_idx": "PFUSdev01",
  2925. "tray_type": "PLA",
  2926. "tray_sub_brands": "Devil Design PLA",
  2927. "tray_color": "FF0000FF",
  2928. "nozzle_temp_min": 220,
  2929. "nozzle_temp_max": 240,
  2930. "cali_idx": 7,
  2931. "nozzle_diameter": "0.4",
  2932. "k_value": 0.028,
  2933. },
  2934. )
  2935. assert response.status_code == 200
  2936. kp_result = await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2937. kp = kp_result.scalar_one_or_none()
  2938. assert kp is not None
  2939. assert kp.cali_idx == 7
  2940. assert kp.k_value == pytest.approx(0.028)
  2941. assert kp.extruder == 0
  2942. assert kp.nozzle_diameter == "0.4"
  2943. assert kp.name == "Devil Design PLA"
  2944. @pytest.mark.asyncio
  2945. @pytest.mark.integration
  2946. async def test_no_assignment_no_persist(
  2947. self,
  2948. async_client: AsyncClient,
  2949. db_session,
  2950. printer_factory,
  2951. ):
  2952. """No SpoolAssignment AND no SpoolmanSlotAssignment → no DB write, MQTT still sent."""
  2953. from backend.app.models.spool_k_profile import SpoolKProfile
  2954. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2955. printer = await printer_factory(model="H2D")
  2956. mock_client = MagicMock()
  2957. mock_client.ams_set_filament_setting.return_value = True
  2958. mock_client.extrusion_cali_sel.return_value = True
  2959. mock_client.request_status_update.return_value = True
  2960. mock_state = MagicMock()
  2961. mock_state.ams_extruder_map = {"0": 0}
  2962. mock_state.raw_data = {"ams": {"ams": []}}
  2963. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2964. mock_pm.get_client.return_value = mock_client
  2965. mock_pm.get_status.return_value = mock_state
  2966. response = await async_client.post(
  2967. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2968. params={
  2969. "tray_info_idx": "GFL05",
  2970. "tray_type": "PLA",
  2971. "tray_sub_brands": "PLA Basic",
  2972. "tray_color": "FFFFFFFF",
  2973. "nozzle_temp_min": 190,
  2974. "nozzle_temp_max": 230,
  2975. "cali_idx": 5,
  2976. "k_value": 0.020,
  2977. },
  2978. )
  2979. assert response.status_code == 200
  2980. # MQTT sent (was successful), but no DB writes
  2981. mock_client.extrusion_cali_sel.assert_called_once()
  2982. local_count = (await db_session.execute(select(SpoolKProfile))).scalars().all()
  2983. sm_count = (await db_session.execute(select(SpoolmanKProfile))).scalars().all()
  2984. assert len(local_count) == 0
  2985. assert len(sm_count) == 0
  2986. @pytest.mark.asyncio
  2987. @pytest.mark.integration
  2988. async def test_negative_cali_idx_no_persist(
  2989. self,
  2990. async_client: AsyncClient,
  2991. db_session,
  2992. printer_factory,
  2993. ):
  2994. """cali_idx=-1 (no profile selected) → no DB write even when assignment exists."""
  2995. from backend.app.models.settings import Settings
  2996. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2997. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2998. # A spoolman_slot_assignments row only exists in Spoolman mode, and
  2999. # since #2812 the mode is asked for explicitly rather than inferred
  3000. # from which table happens to hold rows.
  3001. db_session.add(Settings(key="spoolman_enabled", value="true"))
  3002. printer = await printer_factory(model="H2D")
  3003. db_session.add(
  3004. SpoolmanSlotAssignment(
  3005. printer_id=printer.id,
  3006. ams_id=0,
  3007. tray_id=3,
  3008. spoolman_spool_id=216,
  3009. )
  3010. )
  3011. await db_session.commit()
  3012. mock_client = MagicMock()
  3013. mock_client.ams_set_filament_setting.return_value = True
  3014. mock_client.extrusion_cali_sel.return_value = True
  3015. mock_client.extrusion_cali_set.return_value = True
  3016. mock_client.request_status_update.return_value = True
  3017. mock_state = MagicMock()
  3018. mock_state.ams_extruder_map = {"0": 0}
  3019. mock_state.raw_data = {"ams": {"ams": []}}
  3020. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3021. mock_pm.get_client.return_value = mock_client
  3022. mock_pm.get_status.return_value = mock_state
  3023. response = await async_client.post(
  3024. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  3025. params={
  3026. "tray_info_idx": "GFL05",
  3027. "tray_type": "PLA",
  3028. "tray_sub_brands": "PLA Basic",
  3029. "tray_color": "FFFFFFFF",
  3030. "nozzle_temp_min": 190,
  3031. "nozzle_temp_max": 230,
  3032. "cali_idx": -1,
  3033. "k_value": 0.0,
  3034. },
  3035. )
  3036. assert response.status_code == 200
  3037. sm_kps = (
  3038. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  3039. .scalars()
  3040. .all()
  3041. )
  3042. assert len(sm_kps) == 0 # cali_idx=-1 means "no profile" — don't write
  3043. @pytest.mark.asyncio
  3044. @pytest.mark.integration
  3045. async def test_zero_cali_idx_persists(
  3046. self,
  3047. async_client: AsyncClient,
  3048. db_session,
  3049. printer_factory,
  3050. ):
  3051. """cali_idx=0 is the first valid profile slot (NOT a sentinel for missing)."""
  3052. from backend.app.models.settings import Settings
  3053. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  3054. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  3055. # A spoolman_slot_assignments row only exists in Spoolman mode, and
  3056. # since #2812 the mode is asked for explicitly rather than inferred
  3057. # from which table happens to hold rows.
  3058. db_session.add(Settings(key="spoolman_enabled", value="true"))
  3059. printer = await printer_factory(model="H2D")
  3060. db_session.add(
  3061. SpoolmanSlotAssignment(
  3062. printer_id=printer.id,
  3063. ams_id=0,
  3064. tray_id=3,
  3065. spoolman_spool_id=216,
  3066. )
  3067. )
  3068. await db_session.commit()
  3069. mock_client = MagicMock()
  3070. mock_client.ams_set_filament_setting.return_value = True
  3071. mock_client.extrusion_cali_sel.return_value = True
  3072. mock_client.request_status_update.return_value = True
  3073. mock_state = MagicMock()
  3074. mock_state.ams_extruder_map = {"0": 0}
  3075. mock_state.raw_data = {"ams": {"ams": []}}
  3076. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3077. mock_pm.get_client.return_value = mock_client
  3078. mock_pm.get_status.return_value = mock_state
  3079. response = await async_client.post(
  3080. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  3081. params={
  3082. "tray_info_idx": "GFL05",
  3083. "tray_type": "PLA",
  3084. "tray_sub_brands": "PLA Basic",
  3085. "tray_color": "FFFFFFFF",
  3086. "nozzle_temp_min": 190,
  3087. "nozzle_temp_max": 230,
  3088. "cali_idx": 0,
  3089. "k_value": 0.020,
  3090. },
  3091. )
  3092. assert response.status_code == 200
  3093. kp = (
  3094. await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  3095. ).scalar_one_or_none()
  3096. assert kp is not None
  3097. assert kp.cali_idx == 0 # explicitly testing 0 is valid
  3098. @pytest.mark.asyncio
  3099. @pytest.mark.integration
  3100. async def test_upsert_idempotent(
  3101. self,
  3102. async_client: AsyncClient,
  3103. db_session,
  3104. printer_factory,
  3105. ):
  3106. """Repeated POSTs update the same row (UNIQUE on spool_id+printer+extruder+nozzle_diameter)."""
  3107. from backend.app.models.settings import Settings
  3108. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  3109. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  3110. # A spoolman_slot_assignments row only exists in Spoolman mode, and
  3111. # since #2812 the mode is asked for explicitly rather than inferred
  3112. # from which table happens to hold rows.
  3113. db_session.add(Settings(key="spoolman_enabled", value="true"))
  3114. printer = await printer_factory(model="H2D")
  3115. db_session.add(
  3116. SpoolmanSlotAssignment(
  3117. printer_id=printer.id,
  3118. ams_id=0,
  3119. tray_id=3,
  3120. spoolman_spool_id=216,
  3121. )
  3122. )
  3123. await db_session.commit()
  3124. mock_client = MagicMock()
  3125. mock_client.ams_set_filament_setting.return_value = True
  3126. mock_client.extrusion_cali_sel.return_value = True
  3127. mock_client.request_status_update.return_value = True
  3128. mock_state = MagicMock()
  3129. mock_state.ams_extruder_map = {"0": 0}
  3130. mock_state.raw_data = {"ams": {"ams": []}}
  3131. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3132. mock_pm.get_client.return_value = mock_client
  3133. mock_pm.get_status.return_value = mock_state
  3134. # First call with cali_idx=5
  3135. await async_client.post(
  3136. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  3137. params={
  3138. "tray_info_idx": "GFL05",
  3139. "tray_type": "PLA",
  3140. "tray_sub_brands": "PLA Basic",
  3141. "tray_color": "FFFFFFFF",
  3142. "nozzle_temp_min": 190,
  3143. "nozzle_temp_max": 230,
  3144. "cali_idx": 5,
  3145. "k_value": 0.020,
  3146. },
  3147. )
  3148. # Second call with cali_idx=10 (same slot/spool/extruder/nozzle)
  3149. await async_client.post(
  3150. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  3151. params={
  3152. "tray_info_idx": "GFL05",
  3153. "tray_type": "PLA",
  3154. "tray_sub_brands": "PLA Matte",
  3155. "tray_color": "FFFFFFFF",
  3156. "nozzle_temp_min": 190,
  3157. "nozzle_temp_max": 230,
  3158. "cali_idx": 10,
  3159. "k_value": 0.025,
  3160. },
  3161. )
  3162. # Should be exactly ONE row (updated), not two
  3163. kps = (
  3164. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  3165. .scalars()
  3166. .all()
  3167. )
  3168. assert len(kps) == 1
  3169. assert kps[0].cali_idx == 10 # updated to most recent
  3170. assert kps[0].name == "PLA Matte"
  3171. @pytest.mark.asyncio
  3172. @pytest.mark.integration
  3173. async def test_external_slot_extruder_inversion(
  3174. self,
  3175. async_client: AsyncClient,
  3176. db_session,
  3177. printer_factory,
  3178. ):
  3179. """ams_id=255 + tray_id=0 → kp.extruder=1 (ext-L); tray_id=1 → extruder=0 (ext-R)."""
  3180. from backend.app.models.spool import Spool
  3181. from backend.app.models.spool_assignment import SpoolAssignment
  3182. from backend.app.models.spool_k_profile import SpoolKProfile
  3183. printer = await printer_factory(model="H2D")
  3184. spool = Spool(material="PLA")
  3185. db_session.add(spool)
  3186. await db_session.flush()
  3187. # Note: SpoolmanSlotAssignment can't store ams_id=255 with tray_id=1
  3188. # under the ck_tray_id_range constraint (0-3 valid). External-slot
  3189. # K-profile persistence is therefore tested via local SpoolAssignment.
  3190. db_session.add(
  3191. SpoolAssignment(
  3192. spool_id=spool.id,
  3193. printer_id=printer.id,
  3194. ams_id=255,
  3195. tray_id=0,
  3196. )
  3197. )
  3198. await db_session.commit()
  3199. spool_id = spool.id
  3200. mock_client = MagicMock()
  3201. mock_client.ams_set_filament_setting.return_value = True
  3202. mock_client.extrusion_cali_sel.return_value = True
  3203. mock_client.request_status_update.return_value = True
  3204. mock_state = MagicMock()
  3205. mock_state.ams_extruder_map = {"0": 0} # truthy so external-inversion path runs
  3206. mock_state.raw_data = {"ams": {"ams": []}}
  3207. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3208. mock_pm.get_client.return_value = mock_client
  3209. mock_pm.get_status.return_value = mock_state
  3210. response = await async_client.post(
  3211. f"/api/v1/printers/{printer.id}/slots/255/0/configure",
  3212. params={
  3213. "tray_info_idx": "GFL05",
  3214. "tray_type": "PLA",
  3215. "tray_sub_brands": "PLA Basic",
  3216. "tray_color": "FFFFFFFF",
  3217. "nozzle_temp_min": 190,
  3218. "nozzle_temp_max": 230,
  3219. "cali_idx": 5,
  3220. "k_value": 0.020,
  3221. },
  3222. )
  3223. assert response.status_code == 200
  3224. kp = (
  3225. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  3226. ).scalar_one_or_none()
  3227. assert kp is not None
  3228. # tray_id=0 → extruder = 1 - 0 = 1
  3229. assert kp.extruder == 1
  3230. @pytest.mark.asyncio
  3231. @pytest.mark.integration
  3232. async def test_dual_nozzle_extruder_persists(
  3233. self,
  3234. async_client: AsyncClient,
  3235. db_session,
  3236. printer_factory,
  3237. ):
  3238. """ams_extruder_map with extruder=1 → kp.extruder=1 persisted correctly."""
  3239. from backend.app.models.spool import Spool
  3240. from backend.app.models.spool_assignment import SpoolAssignment
  3241. from backend.app.models.spool_k_profile import SpoolKProfile
  3242. printer = await printer_factory(model="H2D")
  3243. spool = Spool(material="PLA")
  3244. db_session.add(spool)
  3245. await db_session.flush()
  3246. db_session.add(
  3247. SpoolAssignment(
  3248. spool_id=spool.id,
  3249. printer_id=printer.id,
  3250. ams_id=2,
  3251. tray_id=3,
  3252. )
  3253. )
  3254. await db_session.commit()
  3255. spool_id = spool.id
  3256. mock_client = MagicMock()
  3257. mock_client.ams_set_filament_setting.return_value = True
  3258. mock_client.extrusion_cali_sel.return_value = True
  3259. mock_client.request_status_update.return_value = True
  3260. mock_state = MagicMock()
  3261. mock_state.ams_extruder_map = {"2": 1} # AMS 2 is on extruder 1
  3262. mock_state.raw_data = {"ams": {"ams": []}}
  3263. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3264. mock_pm.get_client.return_value = mock_client
  3265. mock_pm.get_status.return_value = mock_state
  3266. response = await async_client.post(
  3267. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  3268. params={
  3269. "tray_info_idx": "GFL05",
  3270. "tray_type": "PLA",
  3271. "tray_sub_brands": "PLA Basic",
  3272. "tray_color": "FFFFFFFF",
  3273. "nozzle_temp_min": 190,
  3274. "nozzle_temp_max": 230,
  3275. "cali_idx": 5,
  3276. "k_value": 0.020,
  3277. },
  3278. )
  3279. assert response.status_code == 200
  3280. kp = (
  3281. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  3282. ).scalar_one_or_none()
  3283. assert kp is not None
  3284. assert kp.extruder == 1
  3285. @pytest.mark.asyncio
  3286. @pytest.mark.integration
  3287. async def test_db_error_does_not_fail_endpoint(
  3288. self,
  3289. async_client: AsyncClient,
  3290. db_session,
  3291. printer_factory,
  3292. ):
  3293. """DB errors during K-profile persistence are best-effort — endpoint still returns 200.
  3294. Verifies the try/except wrap added in P13-3b: if DB upsert fails (e.g.
  3295. because the schema is out of sync, a constraint violation, or any
  3296. other transient error), the MQTT command was already sent successfully
  3297. so we shouldn't return 500 to the user. The error is logged and the
  3298. endpoint returns success.
  3299. """
  3300. from backend.app.models.settings import Settings
  3301. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  3302. # A spoolman_slot_assignments row only exists in Spoolman mode, and
  3303. # since #2812 the mode is asked for explicitly rather than inferred
  3304. # from which table happens to hold rows.
  3305. db_session.add(Settings(key="spoolman_enabled", value="true"))
  3306. printer = await printer_factory(model="H2D")
  3307. db_session.add(
  3308. SpoolmanSlotAssignment(
  3309. printer_id=printer.id,
  3310. ams_id=0,
  3311. tray_id=3,
  3312. spoolman_spool_id=216,
  3313. )
  3314. )
  3315. await db_session.commit()
  3316. mock_client = MagicMock()
  3317. mock_client.ams_set_filament_setting.return_value = True
  3318. mock_client.extrusion_cali_sel.return_value = True
  3319. mock_client.request_status_update.return_value = True
  3320. mock_state = MagicMock()
  3321. mock_state.ams_extruder_map = {"0": 0}
  3322. mock_state.raw_data = {"ams": {"ams": []}}
  3323. # Force the K-profile persistence path to fail by patching the
  3324. # SpoolmanKProfile model class with a sentinel that raises when
  3325. # instantiated. The MQTT call has already happened by then, so the
  3326. # endpoint must catch and log without returning 500.
  3327. with (
  3328. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  3329. patch(
  3330. "backend.app.models.spoolman_k_profile.SpoolmanKProfile",
  3331. side_effect=RuntimeError("Simulated DB error"),
  3332. ),
  3333. ):
  3334. mock_pm.get_client.return_value = mock_client
  3335. mock_pm.get_status.return_value = mock_state
  3336. response = await async_client.post(
  3337. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  3338. params={
  3339. "tray_info_idx": "GFL05",
  3340. "tray_type": "PLA",
  3341. "tray_sub_brands": "PLA Basic",
  3342. "tray_color": "FFFFFFFF",
  3343. "nozzle_temp_min": 190,
  3344. "nozzle_temp_max": 230,
  3345. "cali_idx": 5,
  3346. "k_value": 0.020,
  3347. },
  3348. )
  3349. # Endpoint returns success — MQTT was sent, K-profile failed silently
  3350. assert response.status_code == 200
  3351. # MQTT was indeed called
  3352. mock_client.extrusion_cali_sel.assert_called_once()
  3353. class TestPrinterAccessCodeVisibility:
  3354. """Regression coverage: GET /printers and GET /printers/{id} must NOT
  3355. return ``access_code`` to callers without PRINTERS_UPDATE authority.
  3356. Holding ``access_code`` lets the caller talk to the printer's MQTT
  3357. directly with serial+code, bypassing every PRINTERS_CONTROL /
  3358. PRINTERS_FILES / PRINTERS_AMS_RFID check Bambuddy enforces.
  3359. Trust matrix encoded here:
  3360. - Auth disabled → access_code visible (single-trust mode)
  3361. - JWT Admin → access_code visible
  3362. - JWT Operator (has *_UPDATE) → access_code visible (VP-card UX)
  3363. - JWT Viewer → access_code STRIPPED
  3364. - API key with can_read_status → access_code STRIPPED
  3365. """
  3366. @pytest.fixture
  3367. async def auth_setup(self, async_client: AsyncClient):
  3368. await async_client.post(
  3369. "/api/v1/auth/setup",
  3370. json={
  3371. "auth_enabled": True,
  3372. "admin_username": "pcadmin",
  3373. "admin_password": "AdminPass1!",
  3374. },
  3375. )
  3376. async def _login(username, password):
  3377. resp = await async_client.post(
  3378. "/api/v1/auth/login",
  3379. json={"username": username, "password": password},
  3380. )
  3381. return resp.json()["access_token"]
  3382. admin_token = await _login("pcadmin", "AdminPass1!")
  3383. groups = (
  3384. await async_client.get(
  3385. "/api/v1/groups/",
  3386. headers={"Authorization": f"Bearer {admin_token}"},
  3387. )
  3388. ).json()
  3389. operators_group = next(g for g in groups if g["name"] == "Operators")
  3390. viewers_group = next(g for g in groups if g["name"] == "Viewers")
  3391. for username, password, group in (
  3392. ("pcoperator", "Operpass1!", operators_group["id"]),
  3393. ("pcviewer", "Viewpass1!", viewers_group["id"]),
  3394. ):
  3395. await async_client.post(
  3396. "/api/v1/users/",
  3397. headers={"Authorization": f"Bearer {admin_token}"},
  3398. json={"username": username, "password": password, "group_ids": [group]},
  3399. )
  3400. operator_token = await _login("pcoperator", "Operpass1!")
  3401. viewer_token = await _login("pcviewer", "Viewpass1!")
  3402. return {
  3403. "admin_token": admin_token,
  3404. "operator_token": operator_token,
  3405. "viewer_token": viewer_token,
  3406. }
  3407. async def _seed_printer_with_known_code(self, async_client: AsyncClient, admin_token: str) -> int:
  3408. resp = await async_client.post(
  3409. "/api/v1/printers/",
  3410. headers={"Authorization": f"Bearer {admin_token}"},
  3411. json={
  3412. "name": "AC-Visibility",
  3413. "serial_number": "00M09AVISIBILITY",
  3414. "ip_address": "192.168.42.42",
  3415. "access_code": "SECRET-CODE",
  3416. "is_active": True,
  3417. "model": "X1C",
  3418. },
  3419. )
  3420. assert resp.status_code == 200, resp.text
  3421. return resp.json()["id"]
  3422. @pytest.mark.asyncio
  3423. @pytest.mark.integration
  3424. async def test_auth_disabled_includes_access_code(self, async_client: AsyncClient, printer_factory):
  3425. """Single-trust mode: behaviour preserved, code is visible."""
  3426. printer = await printer_factory(name="AuthOff", access_code="LOCAL-CODE")
  3427. list_resp = await async_client.get("/api/v1/printers/")
  3428. detail_resp = await async_client.get(f"/api/v1/printers/{printer.id}")
  3429. assert list_resp.status_code == 200
  3430. assert detail_resp.status_code == 200
  3431. match = next(p for p in list_resp.json() if p["id"] == printer.id)
  3432. assert match["access_code"] == "LOCAL-CODE"
  3433. assert detail_resp.json()["access_code"] == "LOCAL-CODE"
  3434. @pytest.mark.asyncio
  3435. @pytest.mark.integration
  3436. async def test_admin_jwt_includes_access_code(self, async_client: AsyncClient, auth_setup):
  3437. printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
  3438. headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
  3439. list_resp = await async_client.get("/api/v1/printers/", headers=headers)
  3440. detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
  3441. assert list_resp.status_code == 200
  3442. assert detail_resp.status_code == 200
  3443. match = next(p for p in list_resp.json() if p["id"] == printer_id)
  3444. assert match["access_code"] == "SECRET-CODE"
  3445. assert detail_resp.json()["access_code"] == "SECRET-CODE"
  3446. @pytest.mark.asyncio
  3447. @pytest.mark.integration
  3448. async def test_operator_jwt_includes_access_code(self, async_client: AsyncClient, auth_setup):
  3449. """Operators hold PRINTERS_UPDATE (default role) — the VP-card UX
  3450. surfaces the target printer's access_code so they can configure
  3451. their slicer. The visibility predicate must keep working for them.
  3452. """
  3453. printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
  3454. headers = {"Authorization": f"Bearer {auth_setup['operator_token']}"}
  3455. list_resp = await async_client.get("/api/v1/printers/", headers=headers)
  3456. detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
  3457. assert list_resp.status_code == 200
  3458. assert detail_resp.status_code == 200
  3459. match = next(p for p in list_resp.json() if p["id"] == printer_id)
  3460. assert match["access_code"] == "SECRET-CODE"
  3461. assert detail_resp.json()["access_code"] == "SECRET-CODE"
  3462. @pytest.mark.asyncio
  3463. @pytest.mark.integration
  3464. async def test_viewer_jwt_excludes_access_code(self, async_client: AsyncClient, auth_setup):
  3465. """The fix: Viewers hold PRINTERS_READ but not PRINTERS_UPDATE, and
  3466. must NOT be able to read the printer's secret.
  3467. """
  3468. printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
  3469. headers = {"Authorization": f"Bearer {auth_setup['viewer_token']}"}
  3470. list_resp = await async_client.get("/api/v1/printers/", headers=headers)
  3471. detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
  3472. assert list_resp.status_code == 200
  3473. assert detail_resp.status_code == 200
  3474. match = next(p for p in list_resp.json() if p["id"] == printer_id)
  3475. # Field absent OR null — both are acceptable (no usable secret reaches the wire).
  3476. assert "access_code" not in match or match["access_code"] is None
  3477. body = detail_resp.json()
  3478. assert "access_code" not in body or body["access_code"] is None
  3479. # And the rest of the payload still arrives so the UI keeps working.
  3480. assert match["name"] == "AC-Visibility"
  3481. assert body["name"] == "AC-Visibility"
  3482. @pytest.mark.asyncio
  3483. @pytest.mark.integration
  3484. async def test_api_key_excludes_access_code(self, async_client: AsyncClient, auth_setup, db_session):
  3485. """API keys with can_read_status hold PRINTERS_READ but the predicate
  3486. gates on PRINTERS_UPDATE (admin-only / API-key-unmapped). The key
  3487. must NOT be able to exfiltrate access_code.
  3488. """
  3489. from backend.app.core.auth import generate_api_key
  3490. from backend.app.models.api_key import APIKey
  3491. printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
  3492. full_key, key_hash, key_prefix = generate_api_key()
  3493. api_key = APIKey(
  3494. name="visibility-key",
  3495. key_hash=key_hash,
  3496. key_prefix=key_prefix,
  3497. can_read_status=True,
  3498. enabled=True,
  3499. )
  3500. db_session.add(api_key)
  3501. await db_session.commit()
  3502. list_resp = await async_client.get("/api/v1/printers/", headers={"X-API-Key": full_key})
  3503. detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers={"X-API-Key": full_key})
  3504. assert list_resp.status_code == 200
  3505. assert detail_resp.status_code == 200
  3506. match = next(p for p in list_resp.json() if p["id"] == printer_id)
  3507. assert "access_code" not in match or match["access_code"] is None
  3508. body = detail_resp.json()
  3509. assert "access_code" not in body or body["access_code"] is None
  3510. class TestSetNozzleTemperatureAPI:
  3511. """Integration tests for POST /printers/{id}/temperature/nozzle (#1661)."""
  3512. @pytest.mark.asyncio
  3513. @pytest.mark.integration
  3514. async def test_not_found(self, async_client: AsyncClient):
  3515. response = await async_client.post("/api/v1/printers/99999/temperature/nozzle?target=220")
  3516. assert response.status_code == 404
  3517. @pytest.mark.asyncio
  3518. @pytest.mark.integration
  3519. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3520. printer = await printer_factory(name="P", model="X1C")
  3521. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3522. mock_pm.get_client.return_value = None
  3523. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220")
  3524. assert response.status_code == 400
  3525. @pytest.mark.asyncio
  3526. @pytest.mark.integration
  3527. async def test_default_nozzle_index(self, async_client: AsyncClient, printer_factory):
  3528. """Omitting nozzle defaults to 0 (right/default)."""
  3529. printer = await printer_factory(name="P", model="X1C")
  3530. mock_client = MagicMock()
  3531. mock_client.set_nozzle_temperature.return_value = True
  3532. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3533. mock_pm.get_client.return_value = mock_client
  3534. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220")
  3535. assert response.status_code == 200
  3536. mock_client.set_nozzle_temperature.assert_called_once_with(220, 0)
  3537. @pytest.mark.asyncio
  3538. @pytest.mark.integration
  3539. async def test_dual_nozzle_left(self, async_client: AsyncClient, printer_factory):
  3540. """nozzle=1 reaches the client method as the second positional arg."""
  3541. printer = await printer_factory(name="P", model="H2D")
  3542. mock_client = MagicMock()
  3543. mock_client.set_nozzle_temperature.return_value = True
  3544. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3545. mock_pm.get_client.return_value = mock_client
  3546. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=260&nozzle=1")
  3547. assert response.status_code == 200
  3548. mock_client.set_nozzle_temperature.assert_called_once_with(260, 1)
  3549. @pytest.mark.asyncio
  3550. @pytest.mark.integration
  3551. async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
  3552. """target=0 turns the heater off; must NOT be rejected by Query bounds."""
  3553. printer = await printer_factory(name="P", model="X1C")
  3554. mock_client = MagicMock()
  3555. mock_client.set_nozzle_temperature.return_value = True
  3556. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3557. mock_pm.get_client.return_value = mock_client
  3558. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=0")
  3559. assert response.status_code == 200
  3560. mock_client.set_nozzle_temperature.assert_called_once_with(0, 0)
  3561. @pytest.mark.asyncio
  3562. @pytest.mark.integration
  3563. async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  3564. printer = await printer_factory(name="P", model="X1C")
  3565. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=400")
  3566. assert response.status_code == 422 # FastAPI bounds violation
  3567. @pytest.mark.asyncio
  3568. @pytest.mark.integration
  3569. async def test_nozzle_index_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  3570. printer = await printer_factory(name="P", model="H2D")
  3571. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220&nozzle=2")
  3572. assert response.status_code == 422
  3573. @pytest.mark.asyncio
  3574. @pytest.mark.integration
  3575. async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  3576. printer = await printer_factory(name="P", model="X1C")
  3577. mock_client = MagicMock()
  3578. mock_client.set_nozzle_temperature.return_value = False
  3579. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3580. mock_pm.get_client.return_value = mock_client
  3581. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220")
  3582. assert response.status_code == 500
  3583. class TestSetBedTemperatureAPI:
  3584. """Integration tests for POST /printers/{id}/temperature/bed (#1661)."""
  3585. @pytest.mark.asyncio
  3586. @pytest.mark.integration
  3587. async def test_not_found(self, async_client: AsyncClient):
  3588. response = await async_client.post("/api/v1/printers/99999/temperature/bed?target=60")
  3589. assert response.status_code == 404
  3590. @pytest.mark.asyncio
  3591. @pytest.mark.integration
  3592. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3593. printer = await printer_factory(name="P", model="X1C")
  3594. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3595. mock_pm.get_client.return_value = None
  3596. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=60")
  3597. assert response.status_code == 400
  3598. @pytest.mark.asyncio
  3599. @pytest.mark.integration
  3600. async def test_success(self, async_client: AsyncClient, printer_factory):
  3601. printer = await printer_factory(name="P", model="X1C")
  3602. mock_client = MagicMock()
  3603. mock_client.set_bed_temperature.return_value = True
  3604. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3605. mock_pm.get_client.return_value = mock_client
  3606. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=60")
  3607. assert response.status_code == 200
  3608. mock_client.set_bed_temperature.assert_called_once_with(60)
  3609. @pytest.mark.asyncio
  3610. @pytest.mark.integration
  3611. async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
  3612. """target=0 turns the bed heater off."""
  3613. printer = await printer_factory(name="P", model="X1C")
  3614. mock_client = MagicMock()
  3615. mock_client.set_bed_temperature.return_value = True
  3616. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3617. mock_pm.get_client.return_value = mock_client
  3618. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=0")
  3619. assert response.status_code == 200
  3620. mock_client.set_bed_temperature.assert_called_once_with(0)
  3621. @pytest.mark.asyncio
  3622. @pytest.mark.integration
  3623. async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  3624. printer = await printer_factory(name="P", model="X1C")
  3625. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=200")
  3626. assert response.status_code == 422
  3627. @pytest.mark.asyncio
  3628. @pytest.mark.integration
  3629. async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  3630. printer = await printer_factory(name="P", model="X1C")
  3631. mock_client = MagicMock()
  3632. mock_client.set_bed_temperature.return_value = False
  3633. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3634. mock_pm.get_client.return_value = mock_client
  3635. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=60")
  3636. assert response.status_code == 500
  3637. class TestSetChamberTemperatureAPI:
  3638. """Integration tests for POST /printers/{id}/temperature/chamber.
  3639. Gated on supports_chamber_heater(model). Sensor-only models that report
  3640. chamber temp but have no heater (X1C, X1E, P2S) get a 400 at the route
  3641. level rather than a silent no-op at the firmware level.
  3642. """
  3643. @pytest.mark.asyncio
  3644. @pytest.mark.integration
  3645. async def test_not_found(self, async_client: AsyncClient):
  3646. response = await async_client.post("/api/v1/printers/99999/temperature/chamber?target=45")
  3647. assert response.status_code == 404
  3648. @pytest.mark.asyncio
  3649. @pytest.mark.integration
  3650. @pytest.mark.parametrize("sensor_only_model", ["X1C", "X1E", "P2S"])
  3651. async def test_sensor_only_model_rejected(self, async_client: AsyncClient, printer_factory, sensor_only_model):
  3652. """Models with sensor but no heater must 400 before any client call."""
  3653. printer = await printer_factory(name="P", model=sensor_only_model)
  3654. mock_client = MagicMock()
  3655. mock_client.set_chamber_temperature.return_value = True
  3656. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3657. mock_pm.get_client.return_value = mock_client
  3658. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
  3659. assert response.status_code == 400
  3660. # Client must NOT be called for sensor-only models.
  3661. mock_client.set_chamber_temperature.assert_not_called()
  3662. @pytest.mark.asyncio
  3663. @pytest.mark.integration
  3664. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3665. printer = await printer_factory(name="P", model="H2D")
  3666. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3667. mock_pm.get_client.return_value = None
  3668. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
  3669. assert response.status_code == 400
  3670. @pytest.mark.asyncio
  3671. @pytest.mark.integration
  3672. @pytest.mark.parametrize("heater_model", ["H2C", "H2D", "H2DPRO", "H2S", "X2D"])
  3673. async def test_success_per_heater_model(self, async_client: AsyncClient, printer_factory, heater_model):
  3674. """All five heater-equipped models accept the command."""
  3675. printer = await printer_factory(name="P", model=heater_model)
  3676. mock_client = MagicMock()
  3677. mock_client.set_chamber_temperature.return_value = True
  3678. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3679. mock_pm.get_client.return_value = mock_client
  3680. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
  3681. assert response.status_code == 200
  3682. mock_client.set_chamber_temperature.assert_called_once_with(45)
  3683. @pytest.mark.asyncio
  3684. @pytest.mark.integration
  3685. async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
  3686. """target=0 turns the chamber heater off."""
  3687. printer = await printer_factory(name="P", model="H2D")
  3688. mock_client = MagicMock()
  3689. mock_client.set_chamber_temperature.return_value = True
  3690. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3691. mock_pm.get_client.return_value = mock_client
  3692. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=0")
  3693. assert response.status_code == 200
  3694. mock_client.set_chamber_temperature.assert_called_once_with(0)
  3695. @pytest.mark.asyncio
  3696. @pytest.mark.integration
  3697. async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  3698. printer = await printer_factory(name="P", model="H2D")
  3699. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=100")
  3700. assert response.status_code == 422
  3701. @pytest.mark.asyncio
  3702. @pytest.mark.integration
  3703. async def test_ceiling_is_65_not_60(self, async_client: AsyncClient, printer_factory):
  3704. """The H2 series heats the chamber to 65 °C — 65 must be accepted and
  3705. 66 rejected. The route used to cap at 60, which put the top of the H2D
  3706. range out of reach."""
  3707. printer = await printer_factory(name="P", model="H2D")
  3708. mock_client = MagicMock()
  3709. mock_client.set_chamber_temperature.return_value = True
  3710. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3711. mock_pm.get_client.return_value = mock_client
  3712. accepted = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=65")
  3713. assert accepted.status_code == 200
  3714. mock_client.set_chamber_temperature.assert_called_once_with(65)
  3715. rejected = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=66")
  3716. assert rejected.status_code == 422
  3717. @pytest.mark.asyncio
  3718. @pytest.mark.integration
  3719. async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  3720. printer = await printer_factory(name="P", model="H2D")
  3721. mock_client = MagicMock()
  3722. mock_client.set_chamber_temperature.return_value = False
  3723. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3724. mock_pm.get_client.return_value = mock_client
  3725. response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
  3726. assert response.status_code == 500
  3727. class TestSetFanSpeedAPI:
  3728. """Integration tests for POST /printers/{id}/fan-speed (#1661).
  3729. The fan-id mapping (part->1, aux->2, chamber->3, aux2->10) is the
  3730. critical correctness gate — wrong mapping would target the wrong
  3731. physical fan. "aux2" (M106 P10) is the optional left auxiliary part
  3732. cooling fan on P2S/X2D.
  3733. """
  3734. @pytest.mark.asyncio
  3735. @pytest.mark.integration
  3736. async def test_invalid_fan_name_rejected(self, async_client: AsyncClient, printer_factory):
  3737. printer = await printer_factory(name="P", model="X1C")
  3738. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=foo&speed=50")
  3739. assert response.status_code == 400
  3740. @pytest.mark.asyncio
  3741. @pytest.mark.integration
  3742. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3743. printer = await printer_factory(name="P", model="X1C")
  3744. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3745. mock_pm.get_client.return_value = None
  3746. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed=50")
  3747. assert response.status_code == 400
  3748. @pytest.mark.asyncio
  3749. @pytest.mark.integration
  3750. @pytest.mark.parametrize(
  3751. "fan_name,expected_fan_id",
  3752. [("part", 1), ("aux", 2), ("chamber", 3), ("aux2", 10)],
  3753. )
  3754. async def test_fan_id_mapping(self, async_client: AsyncClient, printer_factory, fan_name, expected_fan_id):
  3755. """Verify each fan name maps to the correct hardware fan-id."""
  3756. printer = await printer_factory(name="P", model="X1C")
  3757. mock_client = MagicMock()
  3758. mock_client.set_fan_speed.return_value = True
  3759. # aux2 is presence-gated on the printer reporting airduct part 10, so
  3760. # give the mock a reported speed. Set explicitly rather than leaning on
  3761. # MagicMock's auto-attribute, which would satisfy the gate by accident.
  3762. mock_client.state.left_aux_fan_speed = 0
  3763. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3764. mock_pm.get_client.return_value = mock_client
  3765. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=100")
  3766. assert response.status_code == 200
  3767. called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
  3768. assert called_fan_id == expected_fan_id
  3769. @pytest.mark.asyncio
  3770. @pytest.mark.integration
  3771. async def test_aux2_rejected_when_printer_has_no_left_aux_fan(self, async_client: AsyncClient, printer_factory):
  3772. """A printer that never reports airduct part 10 must not be sent M106 P10.
  3773. Without the gate the endpoint accepted aux2 for every model, so a POST
  3774. against an A1 would fire a command for hardware that does not exist.
  3775. """
  3776. printer = await printer_factory(name="P", model="A1")
  3777. mock_client = MagicMock()
  3778. mock_client.set_fan_speed.return_value = True
  3779. mock_client.state.left_aux_fan_speed = None
  3780. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3781. mock_pm.get_client.return_value = mock_client
  3782. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=aux2&speed=50")
  3783. assert response.status_code == 400
  3784. assert "left auxiliary fan" in response.json()["detail"]
  3785. mock_client.set_fan_speed.assert_not_called()
  3786. @pytest.mark.asyncio
  3787. @pytest.mark.integration
  3788. async def test_other_fans_unaffected_by_the_aux2_gate(self, async_client: AsyncClient, printer_factory):
  3789. """The gate is aux2-only — a base P2S can still drive its built-in fans."""
  3790. printer = await printer_factory(name="P", model="P2S")
  3791. mock_client = MagicMock()
  3792. mock_client.set_fan_speed.return_value = True
  3793. mock_client.state.left_aux_fan_speed = None
  3794. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3795. mock_pm.get_client.return_value = mock_client
  3796. for fan_name in ("part", "aux", "chamber"):
  3797. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=50")
  3798. assert response.status_code == 200, fan_name
  3799. @pytest.mark.asyncio
  3800. @pytest.mark.integration
  3801. @pytest.mark.parametrize(
  3802. "speed_pct,expected_pwm",
  3803. [(0, 0), (50, 128), (100, 255)],
  3804. )
  3805. async def test_pwm_conversion(self, async_client: AsyncClient, printer_factory, speed_pct, expected_pwm):
  3806. """0-100% must convert to 0-255 PWM (round-to-nearest)."""
  3807. printer = await printer_factory(name="P", model="X1C")
  3808. mock_client = MagicMock()
  3809. mock_client.set_fan_speed.return_value = True
  3810. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3811. mock_pm.get_client.return_value = mock_client
  3812. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed={speed_pct}")
  3813. assert response.status_code == 200
  3814. _called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
  3815. assert called_pwm == expected_pwm
  3816. @pytest.mark.asyncio
  3817. @pytest.mark.integration
  3818. @pytest.mark.parametrize(
  3819. "model,expected_label",
  3820. [
  3821. ("P2S", "Exhaust fan"),
  3822. ("X2D", "Exhaust fan"),
  3823. ("X1C", "Chamber fan"),
  3824. ("P1S", "Chamber fan"),
  3825. ("H2D", "Chamber fan"),
  3826. ],
  3827. )
  3828. async def test_chamber_fan_message_matches_model_label(
  3829. self, async_client: AsyncClient, printer_factory, model, expected_label
  3830. ):
  3831. """The success toast must use the same name as the printer card badge.
  3832. On P2S/X2D the big_fan2 fan is labelled "Exhaust"; everywhere else it
  3833. stays "Chamber". A mismatch means the user clicks "Exhaust" and gets
  3834. told "Chamber fan set to N%".
  3835. """
  3836. printer = await printer_factory(name="P", model=model)
  3837. mock_client = MagicMock()
  3838. mock_client.set_fan_speed.return_value = True
  3839. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3840. mock_pm.get_client.return_value = mock_client
  3841. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=chamber&speed=50")
  3842. assert response.status_code == 200
  3843. assert response.json()["message"] == f"{expected_label} set to 50%"
  3844. @pytest.mark.asyncio
  3845. @pytest.mark.integration
  3846. async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  3847. printer = await printer_factory(name="P", model="X1C")
  3848. response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed=150")
  3849. assert response.status_code == 422
  3850. class TestSelectExtruderAPI:
  3851. """Integration tests for POST /printers/{id}/select-extruder (#1661)."""
  3852. @pytest.mark.asyncio
  3853. @pytest.mark.integration
  3854. async def test_not_found(self, async_client: AsyncClient):
  3855. response = await async_client.post("/api/v1/printers/99999/select-extruder?extruder=0")
  3856. assert response.status_code == 404
  3857. @pytest.mark.asyncio
  3858. @pytest.mark.integration
  3859. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3860. printer = await printer_factory(name="P", model="H2D")
  3861. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3862. mock_pm.get_client.return_value = None
  3863. response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder=0")
  3864. assert response.status_code == 400
  3865. @pytest.mark.asyncio
  3866. @pytest.mark.integration
  3867. @pytest.mark.parametrize("extruder", [0, 1])
  3868. async def test_select_each_extruder(self, async_client: AsyncClient, printer_factory, extruder):
  3869. printer = await printer_factory(name="P", model="H2D")
  3870. mock_client = MagicMock()
  3871. mock_client.select_extruder.return_value = True
  3872. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3873. mock_pm.get_client.return_value = mock_client
  3874. response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder={extruder}")
  3875. assert response.status_code == 200
  3876. mock_client.select_extruder.assert_called_once_with(extruder)
  3877. @pytest.mark.asyncio
  3878. @pytest.mark.integration
  3879. async def test_extruder_index_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
  3880. printer = await printer_factory(name="P", model="H2D")
  3881. response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder=2")
  3882. assert response.status_code == 422
  3883. class TestXYJogAPI:
  3884. """Integration tests for POST /printers/{id}/xy-jog (#1661)."""
  3885. @pytest.mark.asyncio
  3886. @pytest.mark.integration
  3887. async def test_zero_movement_rejected(self, async_client: AsyncClient, printer_factory):
  3888. """x=0 AND y=0 (or omitted) must be rejected — no-op jog is a UI bug."""
  3889. printer = await printer_factory(name="P", model="X1C")
  3890. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog")
  3891. assert response.status_code == 400
  3892. @pytest.mark.asyncio
  3893. @pytest.mark.integration
  3894. @pytest.mark.parametrize("x,y", [(201, 0), (0, 201), (-300, 0), (0, -250)])
  3895. async def test_oversize_movement_rejected(self, async_client: AsyncClient, printer_factory, x, y):
  3896. """Per-axis bound is 200mm; over-bound must be rejected."""
  3897. printer = await printer_factory(name="P", model="X1C")
  3898. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x={x}&y={y}")
  3899. assert response.status_code == 400
  3900. @pytest.mark.asyncio
  3901. @pytest.mark.integration
  3902. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3903. printer = await printer_factory(name="P", model="X1C")
  3904. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3905. mock_pm.get_client.return_value = None
  3906. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=10&y=0")
  3907. assert response.status_code == 400
  3908. @pytest.mark.asyncio
  3909. @pytest.mark.integration
  3910. async def test_success_x_only_emits_relative_gcode(self, async_client: AsyncClient, printer_factory):
  3911. """X-only jog is a bare relative move (no M211), wraps in G91/G90, X only."""
  3912. printer = await printer_factory(name="P", model="X1C")
  3913. mock_client = MagicMock()
  3914. mock_client.send_gcode.return_value = True
  3915. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3916. mock_pm.get_client.return_value = mock_client
  3917. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=10&y=0")
  3918. assert response.status_code == 200
  3919. sent = mock_client.send_gcode.call_args.args[0]
  3920. # Never touch M211 — bare move, exactly like the touchscreen (#2579).
  3921. assert "M211" not in sent
  3922. assert sent.startswith("G91\n")
  3923. assert sent.endswith("\nG90")
  3924. assert "X10.00" in sent
  3925. assert "Y" not in sent # y=0 must NOT be included
  3926. @pytest.mark.asyncio
  3927. @pytest.mark.integration
  3928. async def test_success_both_axes(self, async_client: AsyncClient, printer_factory):
  3929. printer = await printer_factory(name="P", model="X1C")
  3930. mock_client = MagicMock()
  3931. mock_client.send_gcode.return_value = True
  3932. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3933. mock_pm.get_client.return_value = mock_client
  3934. response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=-5&y=7")
  3935. assert response.status_code == 200
  3936. sent = mock_client.send_gcode.call_args.args[0]
  3937. assert "X-5.00" in sent and "Y7.00" in sent
  3938. class TestExtruderJogAPI:
  3939. """Integration tests for POST /printers/{id}/extruder-jog (#1661).
  3940. Note: Bambu firmware enforces the cold-extrude guard
  3941. (min-temp refusal) so the route deliberately does not gate on temp.
  3942. """
  3943. @pytest.mark.asyncio
  3944. @pytest.mark.integration
  3945. async def test_zero_distance_rejected(self, async_client: AsyncClient, printer_factory):
  3946. printer = await printer_factory(name="P", model="X1C")
  3947. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=0")
  3948. assert response.status_code == 400
  3949. @pytest.mark.asyncio
  3950. @pytest.mark.integration
  3951. @pytest.mark.parametrize("distance", [101, -200])
  3952. async def test_oversize_distance_rejected(self, async_client: AsyncClient, printer_factory, distance):
  3953. """Per-axis bound is 100mm; over-bound must be rejected."""
  3954. printer = await printer_factory(name="P", model="X1C")
  3955. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance={distance}")
  3956. assert response.status_code == 400
  3957. @pytest.mark.asyncio
  3958. @pytest.mark.integration
  3959. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  3960. printer = await printer_factory(name="P", model="X1C")
  3961. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3962. mock_pm.get_client.return_value = None
  3963. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=5")
  3964. assert response.status_code == 400
  3965. @pytest.mark.asyncio
  3966. @pytest.mark.integration
  3967. async def test_extrude_uses_relative_e_mode(self, async_client: AsyncClient, printer_factory):
  3968. """Extruder jog must wrap with M83 (relative E) and restore M82 (absolute E)."""
  3969. printer = await printer_factory(name="P", model="X1C")
  3970. mock_client = MagicMock()
  3971. mock_client.send_gcode.return_value = True
  3972. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3973. mock_pm.get_client.return_value = mock_client
  3974. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=5")
  3975. assert response.status_code == 200
  3976. sent = mock_client.send_gcode.call_args.args[0]
  3977. assert sent.startswith("M83\n")
  3978. assert sent.endswith("\nM82")
  3979. assert "E5.00" in sent
  3980. @pytest.mark.asyncio
  3981. @pytest.mark.integration
  3982. async def test_retract_uses_negative_distance(self, async_client: AsyncClient, printer_factory):
  3983. printer = await printer_factory(name="P", model="X1C")
  3984. mock_client = MagicMock()
  3985. mock_client.send_gcode.return_value = True
  3986. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  3987. mock_pm.get_client.return_value = mock_client
  3988. response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=-3.5")
  3989. assert response.status_code == 200
  3990. sent = mock_client.send_gcode.call_args.args[0]
  3991. assert "E-3.50" in sent
  3992. class TestCoverWhenFileServiceIsWedged:
  3993. """The cover endpoint must not report a wedged printer as "no cover".
  3994. #2780: once a printer stops completing the FTPS handshake, every cover
  3995. request walked all 16 candidate paths three times over and ended in a 404
  3996. that read as "this print has no thumbnail" — the opposite of the truth.
  3997. """
  3998. @pytest.mark.asyncio
  3999. @pytest.mark.integration
  4000. async def test_returns_503_naming_the_file_service(self, async_client: AsyncClient, printer_factory, db_session):
  4001. printer = await printer_factory(name="Wedged P2S")
  4002. # Spell out the storage fields rather than leaving them to MagicMock:
  4003. # every attribute of a bare mock is truthy, so the storage gate added
  4004. # in #2780 would read this stub as "the print is on internal storage"
  4005. # and answer 404 before the handshake check ever ran. This test is
  4006. # about a printer whose file service stopped answering, so say that
  4007. # its last print went to the card.
  4008. state = MagicMock(
  4009. subtask_name="job",
  4010. state="RUNNING",
  4011. gcode_file=None,
  4012. current_project_url="ftp://job.gcode.3mf",
  4013. sdcard=True,
  4014. sdcard_reported=True,
  4015. )
  4016. with (
  4017. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
  4018. patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
  4019. patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
  4020. patch("backend.app.api.routes.printers.ftps_handshake_blocked", return_value=True),
  4021. patch(
  4022. "backend.app.api.routes.printers.download_file_try_paths_async",
  4023. new=AsyncMock(return_value=False),
  4024. ) as mock_download,
  4025. ):
  4026. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  4027. assert response.status_code == 503, response.text
  4028. assert "TLS" in response.json()["detail"]
  4029. # The whole point: no FTP fan-out against a printer that cannot answer.
  4030. mock_download.assert_not_called()
  4031. class TestCoverWhenThePrintIsOnInternalStorage:
  4032. """The cover lives inside the 3MF, so it is only reachable if the 3MF is.
  4033. #2780: on an H2C or P2S the sliced file routinely stays on internal
  4034. storage, which FTPS does not serve. Walking all sixteen candidate paths to
  4035. arrive at a bare 404 spends connections the printer is already short of
  4036. and tells the user "this print has no cover", which is not what happened.
  4037. """
  4038. @pytest.fixture(autouse=True)
  4039. def _clear_404_cache(self):
  4040. """The route remembers 404s per printer+print so repeat requests skip
  4041. the fan-out. It is module-level, printer ids restart at 1 for each
  4042. test, and both tests here use the same subtask name -- so without this
  4043. the second test reads the first one's cached miss and never reaches
  4044. the code it is checking.
  4045. """
  4046. from backend.app.api.routes.printers import _cover_404_cache
  4047. _cover_404_cache.clear()
  4048. yield
  4049. _cover_404_cache.clear()
  4050. @pytest.mark.asyncio
  4051. @pytest.mark.integration
  4052. async def test_probes_the_named_file_instead_of_fanning_out(
  4053. self, async_client: AsyncClient, printer_factory, db_session
  4054. ):
  4055. """#2856 narrowed this from a skip to a bounded look. The dispatch
  4056. names the file, and an H2D with a card in serves that exact name from
  4057. /cache while reporting `brtc://emmc` -- so ask, but ask once: five
  4058. paths for one name, not sixteen, and no retries on top.
  4059. """
  4060. printer = await printer_factory(name="H2D")
  4061. state = MagicMock(
  4062. subtask_name="job",
  4063. state="RUNNING",
  4064. gcode_file=None,
  4065. current_project_url="brtc://emmc/job.gcode.3mf",
  4066. sdcard=True,
  4067. sdcard_reported=True,
  4068. )
  4069. with (
  4070. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
  4071. patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
  4072. patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
  4073. patch("backend.app.api.routes.printers.ftps_handshake_blocked", return_value=False),
  4074. patch(
  4075. "backend.app.api.routes.printers.download_file_try_paths_async",
  4076. new=AsyncMock(return_value=False),
  4077. ) as mock_download,
  4078. ):
  4079. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  4080. assert response.status_code == 404, response.text
  4081. # A probe that found nothing leaves the printer's own account standing,
  4082. # so the reason still reaches the caller (#2780).
  4083. assert "internal_storage" in response.json()["detail"]
  4084. assert mock_download.await_count == 1, "the probe is one look, not the retry loop"
  4085. assert mock_download.await_args.args[2] == [
  4086. "/job.gcode.3mf",
  4087. "/cache/job.gcode.3mf",
  4088. "/model/job.gcode.3mf",
  4089. "/data/job.gcode.3mf",
  4090. "/data/Metadata/job.gcode.3mf",
  4091. ]
  4092. @pytest.mark.asyncio
  4093. @pytest.mark.integration
  4094. async def test_skips_the_fan_out_and_says_why_with_nothing_to_probe_for(
  4095. self, async_client: AsyncClient, printer_factory, db_session
  4096. ):
  4097. """No dispatch to name a file and an empty slot: nothing to look for
  4098. and nothing to look on, so the route still short-circuits with the
  4099. reason the frontend explains (#2780)."""
  4100. printer = await printer_factory(name="H2C")
  4101. state = MagicMock(
  4102. subtask_name="job",
  4103. state="RUNNING",
  4104. gcode_file=None,
  4105. current_project_url=None,
  4106. sdcard=False,
  4107. sdcard_reported=True,
  4108. )
  4109. with (
  4110. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
  4111. patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
  4112. patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
  4113. patch("backend.app.api.routes.printers.ftps_handshake_blocked", return_value=False),
  4114. patch(
  4115. "backend.app.api.routes.printers.download_file_try_paths_async",
  4116. new=AsyncMock(return_value=False),
  4117. ) as mock_download,
  4118. ):
  4119. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  4120. assert response.status_code == 404, response.text
  4121. assert "no_external_storage" in response.json()["detail"]
  4122. mock_download.assert_not_called()
  4123. @pytest.mark.asyncio
  4124. @pytest.mark.integration
  4125. async def test_a_print_on_external_storage_still_fans_out(
  4126. self, async_client: AsyncClient, printer_factory, db_session
  4127. ):
  4128. """The regression guard. Every install whose covers work today reaches
  4129. the download exactly as before."""
  4130. printer = await printer_factory(name="X1C")
  4131. state = MagicMock(
  4132. subtask_name="job",
  4133. state="RUNNING",
  4134. gcode_file=None,
  4135. current_project_url="ftp://job.gcode.3mf",
  4136. sdcard=True,
  4137. sdcard_reported=True,
  4138. )
  4139. with (
  4140. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
  4141. patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
  4142. patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
  4143. patch("backend.app.api.routes.printers.ftps_handshake_blocked", return_value=False),
  4144. patch(
  4145. "backend.app.api.routes.printers.download_file_try_paths_async",
  4146. new=AsyncMock(return_value=False),
  4147. ) as mock_download,
  4148. ):
  4149. await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  4150. mock_download.assert_called()
  4151. class TestClearPlateOnAPoweredDownPrinter:
  4152. """Releasing the plate-clear gate must not require a reachable printer.
  4153. #2864: with Auto Power Off the end-of-print state is a dirty plate on a
  4154. machine Bambuddy has just switched off. The endpoint answered 400 for
  4155. anything not connected, so the operator who physically cleared that plate
  4156. had no way to say so — not from the API, not from the UI — and everything
  4157. gated on the flag stayed stuck until the printer was powered back on by
  4158. hand. Nothing in the clear path talks to the printer: the flag is
  4159. Bambuddy-side and persisted.
  4160. """
  4161. @pytest.mark.asyncio
  4162. @pytest.mark.integration
  4163. async def test_clear_plate_succeeds_while_disconnected(
  4164. self, async_client: AsyncClient, printer_factory, db_session
  4165. ):
  4166. printer = await printer_factory(name="Powered-off X1C")
  4167. # What the smart-plug power-off leaves behind: the client is still
  4168. # registered, but its state is blanked to unknown/disconnected.
  4169. state = MagicMock(state="unknown", connected=False)
  4170. with (
  4171. patch("backend.app.api.routes.printers.printer_manager.is_connected", return_value=False),
  4172. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
  4173. patch(
  4174. "backend.app.api.routes.printers.printer_manager.is_awaiting_plate_clear",
  4175. return_value=True,
  4176. ),
  4177. patch("backend.app.api.routes.printers.printer_manager.set_awaiting_plate_clear") as mock_set,
  4178. ):
  4179. response = await async_client.post(f"/api/v1/printers/{printer.id}/clear-plate")
  4180. assert response.status_code == 200, response.text
  4181. mock_set.assert_called_once_with(printer.id, False)
  4182. @pytest.mark.asyncio
  4183. @pytest.mark.integration
  4184. async def test_clear_plate_succeeds_with_no_client_state_at_all(
  4185. self, async_client: AsyncClient, printer_factory, db_session
  4186. ):
  4187. """A printer disconnected manually, or never connected since the last
  4188. restart, has no client state — the persisted gate is still releasable."""
  4189. printer = await printer_factory(name="Never connected")
  4190. with (
  4191. patch("backend.app.api.routes.printers.printer_manager.is_connected", return_value=False),
  4192. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=None),
  4193. patch(
  4194. "backend.app.api.routes.printers.printer_manager.is_awaiting_plate_clear",
  4195. return_value=True,
  4196. ),
  4197. patch("backend.app.api.routes.printers.printer_manager.set_awaiting_plate_clear") as mock_set,
  4198. ):
  4199. response = await async_client.post(f"/api/v1/printers/{printer.id}/clear-plate")
  4200. assert response.status_code == 200, response.text
  4201. mock_set.assert_called_once_with(printer.id, False)
  4202. @pytest.mark.asyncio
  4203. @pytest.mark.integration
  4204. async def test_clear_plate_still_rejects_when_there_is_nothing_to_clear(
  4205. self, async_client: AsyncClient, printer_factory, db_session
  4206. ):
  4207. """Dropping the connection guard must not turn the endpoint into a
  4208. no-op accept: an offline printer with a clean plate is still a 400."""
  4209. printer = await printer_factory(name="Powered-off, clean plate")
  4210. with (
  4211. patch("backend.app.api.routes.printers.printer_manager.is_connected", return_value=False),
  4212. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=None),
  4213. patch(
  4214. "backend.app.api.routes.printers.printer_manager.is_awaiting_plate_clear",
  4215. return_value=False,
  4216. ),
  4217. patch("backend.app.api.routes.printers.printer_manager.set_awaiting_plate_clear") as mock_set,
  4218. ):
  4219. response = await async_client.post(f"/api/v1/printers/{printer.id}/clear-plate")
  4220. assert response.status_code == 400, response.text
  4221. mock_set.assert_not_called()
  4222. @pytest.mark.asyncio
  4223. @pytest.mark.integration
  4224. async def test_plate_clear_state_still_reaches_mqtt_without_a_client(
  4225. self, async_client: AsyncClient, printer_factory, db_session
  4226. ):
  4227. """Clearing the gate on a printer with no registered client must still
  4228. update the retained MQTT topic (#2525) — otherwise it keeps telling Home
  4229. Assistant the plate is dirty after it was acknowledged."""
  4230. from backend.app.services.printer_manager import printer_manager
  4231. printer = await printer_factory(name="Disconnected P1S")
  4232. info = await printer_manager._printer_info_from_db(printer.id)
  4233. assert info is not None
  4234. assert info.name == "Disconnected P1S"
  4235. assert info.serial_number == printer.serial_number
  4236. @pytest.mark.asyncio
  4237. @pytest.mark.integration
  4238. async def test_status_reports_the_gate_without_client_state(
  4239. self, async_client: AsyncClient, printer_factory, db_session
  4240. ):
  4241. """Without this the schema default reported a clean plate for a printer
  4242. the DB says is still gated, and the UI hid the control that releases it."""
  4243. printer = await printer_factory(name="No client state")
  4244. with (
  4245. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=None),
  4246. patch(
  4247. "backend.app.api.routes.printers.printer_manager.is_awaiting_plate_clear",
  4248. return_value=True,
  4249. ),
  4250. ):
  4251. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  4252. assert response.status_code == 200, response.text
  4253. body = response.json()
  4254. assert body["connected"] is False
  4255. assert body["awaiting_plate_clear"] is True
  4256. class TestPrintableObjectsReload:
  4257. """Rebuilding the object list must ask the disk before the printer.
  4258. The archive of a running print normally holds the very 3MF the printer is
  4259. executing. Pulling it back over FTPS costs a full transfer from a machine
  4260. that is mid-print — 15 MB on the print this was written for — and on a
  4261. printer that kept the file on internal storage it cannot succeed at all.
  4262. """
  4263. @pytest.mark.asyncio
  4264. @pytest.mark.integration
  4265. async def test_the_archive_on_disk_is_used_before_any_ftp(
  4266. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  4267. ):
  4268. printer = await printer_factory(name="H2C-1")
  4269. await archive_factory(printer.id, status="printing", subtask_id="330151809")
  4270. client = MagicMock()
  4271. client.state = MagicMock(
  4272. printable_objects={},
  4273. skipped_objects=[],
  4274. subtask_id="330151809",
  4275. subtask_name="HULA",
  4276. state="RUNNING",
  4277. gcode_file="/data/Metadata/plate_1.gcode",
  4278. printable_objects_bbox_all=None,
  4279. )
  4280. ftp = AsyncMock(return_value=None)
  4281. with (
  4282. patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client),
  4283. patch(
  4284. "backend.app.services.archive.extract_printable_objects_from_archive",
  4285. return_value=({101: {"name": "left"}, 102: {"name": "right"}}, None),
  4286. ) as from_disk,
  4287. patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp),
  4288. ):
  4289. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  4290. assert response.status_code == 200, response.text
  4291. assert response.json()["total"] == 2
  4292. from_disk.assert_called_once()
  4293. ftp.assert_not_awaited()
  4294. @pytest.mark.asyncio
  4295. @pytest.mark.integration
  4296. async def test_a_reload_that_the_disk_cannot_answer_still_asks_the_printer(
  4297. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  4298. ):
  4299. """The fan-out is the fallback, not dead code: a print whose archive has
  4300. no 3MF (or none at all) still has the printer to ask."""
  4301. printer = await printer_factory(name="H2C-1")
  4302. await archive_factory(printer.id, status="printing", subtask_id="330151809")
  4303. client = MagicMock()
  4304. client.state = MagicMock(
  4305. printable_objects={},
  4306. skipped_objects=[],
  4307. subtask_id="330151809",
  4308. subtask_name="HULA",
  4309. state="RUNNING",
  4310. gcode_file="/data/Metadata/plate_1.gcode",
  4311. printable_objects_bbox_all=None,
  4312. )
  4313. ftp = AsyncMock(return_value=None)
  4314. with (
  4315. patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client),
  4316. patch(
  4317. "backend.app.services.archive.extract_printable_objects_from_archive",
  4318. return_value=({}, None),
  4319. ),
  4320. patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp),
  4321. ):
  4322. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  4323. assert response.status_code == 200, response.text
  4324. ftp.assert_awaited()
  4325. class TestCoverUsesTheRunningPrintsArchive:
  4326. """The cover must not re-fetch a 3MF Bambuddy already has on disk.
  4327. The in-memory download cache dies with the process, so after a restart
  4328. mid-print every cover, top view and skip-objects plate mask went back to the
  4329. printer for the whole file — measured on the maintainer's H2C as three
  4330. concurrent fan-outs, thirteen seconds and a 0-byte read, on a machine that
  4331. was busy printing. The archive of the running print holds that exact file.
  4332. """
  4333. @pytest.mark.asyncio
  4334. @pytest.mark.integration
  4335. async def test_the_archived_3mf_is_preferred_over_ftp(
  4336. self, async_client: AsyncClient, printer_factory, archive_factory, db_session, tmp_path
  4337. ):
  4338. import zipfile
  4339. from backend.app.core.config import settings as app_settings
  4340. printer = await printer_factory(name="H2C-1")
  4341. # A 3MF that is a real zip but carries no cover, so the endpoint reaches
  4342. # its "no cover in this file" answer without any FTP work.
  4343. threemf = tmp_path / "job.3mf"
  4344. with zipfile.ZipFile(threemf, "w") as zf:
  4345. zf.writestr("Metadata/slice_info.config", "<config/>")
  4346. await archive_factory(
  4347. printer.id,
  4348. status="printing",
  4349. subtask_id="330151809",
  4350. file_path=str(threemf.relative_to(tmp_path)),
  4351. )
  4352. state = MagicMock(
  4353. subtask_name="job",
  4354. subtask_id="330151809",
  4355. state="RUNNING",
  4356. gcode_file="/data/Metadata/plate_1.gcode",
  4357. current_project_url="ftp://job.3mf",
  4358. sdcard=True,
  4359. sdcard_reported=True,
  4360. )
  4361. ftp = AsyncMock(return_value=None)
  4362. with (
  4363. patch.object(app_settings, "base_dir", tmp_path),
  4364. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
  4365. patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
  4366. patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
  4367. patch("backend.app.api.routes.printers.download_file_try_paths_async", new=ftp),
  4368. ):
  4369. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  4370. # No cover inside this 3MF, so a 404 — but reached from the file on disk.
  4371. assert response.status_code == 404, response.text
  4372. ftp.assert_not_awaited()
  4373. @pytest.mark.asyncio
  4374. @pytest.mark.integration
  4375. async def test_the_archived_file_is_never_deleted_by_the_cover_flow(
  4376. self, async_client: AsyncClient, printer_factory, archive_factory, db_session, tmp_path
  4377. ):
  4378. """The cover flow unlinks the file it downloaded. Handed the archive's
  4379. own copy, that would delete the print's 3MF."""
  4380. import zipfile
  4381. from backend.app.core.config import settings as app_settings
  4382. printer = await printer_factory(name="H2C-1")
  4383. threemf = tmp_path / "job.3mf"
  4384. with zipfile.ZipFile(threemf, "w") as zf:
  4385. zf.writestr("Metadata/slice_info.config", "<config/>")
  4386. await archive_factory(
  4387. printer.id,
  4388. status="printing",
  4389. subtask_id="330151809",
  4390. file_path=str(threemf.relative_to(tmp_path)),
  4391. )
  4392. state = MagicMock(
  4393. subtask_name="job",
  4394. subtask_id="330151809",
  4395. state="RUNNING",
  4396. gcode_file="/data/Metadata/plate_1.gcode",
  4397. current_project_url="ftp://job.3mf",
  4398. sdcard=True,
  4399. sdcard_reported=True,
  4400. )
  4401. with (
  4402. patch.object(app_settings, "base_dir", tmp_path),
  4403. patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
  4404. patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
  4405. patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
  4406. patch(
  4407. "backend.app.api.routes.printers.download_file_try_paths_async",
  4408. new=AsyncMock(return_value=None),
  4409. ),
  4410. ):
  4411. await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  4412. assert threemf.is_file(), "the cover flow deleted the running print's archived 3MF"
  4413. class TestHMSStopCountsAsUserStop:
  4414. """ "Stop Printing" in the HMS error dialog is a user stop like the Stop button.
  4415. It sends its own stop payload, but used to skip the stopped-by-user mark,
  4416. so the print ended as failed ("print failed" notification, and the cancel
  4417. sequence's own error read as a fault).
  4418. """
  4419. @staticmethod
  4420. async def _act(async_client, printer_id: int, action: str):
  4421. mock_client = MagicMock()
  4422. mock_client._last_message_time = 100.0
  4423. def _send(*_a, **_kw):
  4424. mock_client._last_message_time = 100.5
  4425. return True
  4426. mock_client.execute_hms_action.side_effect = _send
  4427. with (
  4428. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  4429. patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
  4430. ):
  4431. mock_pm.get_client.return_value = mock_client
  4432. return await async_client.post(
  4433. f"/api/v1/printers/{printer_id}/hms/execute-action",
  4434. json={"print_error": "0300800A", "action": action, "job_id": None},
  4435. )
  4436. @pytest.mark.asyncio
  4437. @pytest.mark.integration
  4438. async def test_stop_printing_marks_the_printer(self, async_client: AsyncClient, printer_factory):
  4439. from backend.app.main import _user_stopped_printers
  4440. printer = await printer_factory(name="HMS Printer")
  4441. _user_stopped_printers.discard(printer.id)
  4442. try:
  4443. response = await self._act(async_client, printer.id, "STOP_PRINTING")
  4444. assert response.status_code == 200, response.text
  4445. assert printer.id in _user_stopped_printers
  4446. finally:
  4447. _user_stopped_printers.discard(printer.id)
  4448. @pytest.mark.asyncio
  4449. @pytest.mark.integration
  4450. async def test_other_hms_actions_do_not_mark(self, async_client: AsyncClient, printer_factory):
  4451. from backend.app.main import _user_stopped_printers
  4452. printer = await printer_factory(name="HMS Printer 2")
  4453. _user_stopped_printers.discard(printer.id)
  4454. response = await self._act(async_client, printer.id, "RESUME_PRINTING")
  4455. assert response.status_code == 200, response.text
  4456. assert printer.id not in _user_stopped_printers