test_printers_api.py 210 KB

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