test_printers_api.py 198 KB

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