test_printers_api.py 241 KB

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