test_printers_api.py 172 KB

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