test_printers_api.py 119 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954
  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_3mf_scan_fallback_for_per_plate_archive(
  365. self, async_client: AsyncClient, printer_factory, db_session, tmp_path
  366. ):
  367. """Per-plate archives sliced separately in Bambu Studio contain a
  368. single Metadata/plate_N.gcode (the active plate) but bundle thumbnails
  369. for every plate. With no dispatch record (e.g. dispatched via Studio
  370. directly) and no plate path in gcode_file, the route must scan the
  371. 3MF and pick plate N's thumbnail. See #1166 option 4."""
  372. import zipfile
  373. from unittest.mock import MagicMock, patch
  374. from backend.app.services.bambu_ftp import cache_3mf_download
  375. from backend.app.services.bambu_mqtt import PrinterState
  376. printer = await printer_factory()
  377. # Per-plate archive: thumbnails for all plates, gcode for plate 3 only.
  378. threemf_path = tmp_path / "PerPlate.3mf"
  379. with zipfile.ZipFile(threemf_path, "w") as zf:
  380. for plate in range(1, 5):
  381. zf.writestr(f"Metadata/plate_{plate}.png", f"PLATE_{plate}_PNG".encode())
  382. zf.writestr("Metadata/plate_3.gcode", "; only plate 3 has gcode\n")
  383. cache_3mf_download(printer.id, "PerPlate.3mf", threemf_path)
  384. state = PrinterState()
  385. state.connected = True
  386. state.state = "RUNNING"
  387. state.subtask_name = "PerPlate"
  388. state.gcode_file = "PerPlate.3mf"
  389. # No dispatch record (Studio-direct dispatch).
  390. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  391. mock_pm.get_status = MagicMock(return_value=state)
  392. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  393. response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
  394. assert response.status_code == 200
  395. assert response.content == b"PLATE_3_PNG"
  396. @pytest.mark.asyncio
  397. @pytest.mark.integration
  398. async def test_get_printer_status_omits_fila_switch_when_not_installed(
  399. self, async_client: AsyncClient, printer_factory, db_session
  400. ):
  401. """Without the FTS accessory, fila_switch must be null so the frontend
  402. keeps applying the per-extruder filter on regular dual-nozzle printers."""
  403. from unittest.mock import MagicMock, patch
  404. from backend.app.services.bambu_mqtt import PrinterState
  405. printer = await printer_factory()
  406. state = PrinterState()
  407. state.connected = True
  408. state.state = "IDLE"
  409. # default fila_switch — installed = False
  410. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  411. mock_pm.get_status = MagicMock(return_value=state)
  412. mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
  413. response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
  414. assert response.status_code == 200
  415. result = response.json()
  416. assert result["fila_switch"] is None
  417. # ========================================================================
  418. # Test connection endpoint
  419. # ========================================================================
  420. class TestPrinterDataIntegrity:
  421. """Tests for printer data integrity."""
  422. @pytest.mark.asyncio
  423. @pytest.mark.integration
  424. async def test_printer_stores_all_fields(self, async_client: AsyncClient, printer_factory, db_session):
  425. """Verify printer stores all fields correctly."""
  426. printer = await printer_factory(
  427. name="Full Test Printer",
  428. serial_number="00M09A444444444",
  429. ip_address="192.168.1.150",
  430. model="P1S",
  431. is_active=True,
  432. auto_archive=False,
  433. )
  434. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  435. assert response.status_code == 200
  436. result = response.json()
  437. assert result["name"] == "Full Test Printer"
  438. assert result["serial_number"] == "00M09A444444444"
  439. assert result["ip_address"] == "192.168.1.150"
  440. assert result["model"] == "P1S"
  441. assert result["is_active"] is True
  442. assert result["auto_archive"] is False
  443. @pytest.mark.asyncio
  444. @pytest.mark.integration
  445. async def test_printer_update_persists(self, async_client: AsyncClient, printer_factory, db_session):
  446. """CRITICAL: Verify printer updates persist."""
  447. printer = await printer_factory(name="Original", is_active=True)
  448. # Update
  449. await async_client.patch(f"/api/v1/printers/{printer.id}", json={"name": "Updated", "is_active": False})
  450. # Verify persistence
  451. response = await async_client.get(f"/api/v1/printers/{printer.id}")
  452. result = response.json()
  453. assert result["name"] == "Updated"
  454. assert result["is_active"] is False
  455. # ========================================================================
  456. # Refresh status endpoint
  457. # ========================================================================
  458. @pytest.mark.asyncio
  459. @pytest.mark.integration
  460. async def test_refresh_status_not_found(self, async_client: AsyncClient):
  461. """Verify 404 for non-existent printer."""
  462. response = await async_client.post("/api/v1/printers/99999/refresh-status")
  463. assert response.status_code == 404
  464. @pytest.mark.asyncio
  465. @pytest.mark.integration
  466. async def test_refresh_status_not_connected(self, async_client: AsyncClient, printer_factory):
  467. """Verify 400 when printer is not connected."""
  468. printer = await printer_factory(name="Disconnected Printer")
  469. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  470. mock_pm.request_status_update.return_value = False
  471. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  472. assert response.status_code == 400
  473. assert "not connected" in response.json()["detail"].lower()
  474. @pytest.mark.asyncio
  475. @pytest.mark.integration
  476. async def test_refresh_status_success(self, async_client: AsyncClient, printer_factory):
  477. """Verify successful refresh request."""
  478. printer = await printer_factory(name="Connected Printer")
  479. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  480. mock_pm.request_status_update.return_value = True
  481. response = await async_client.post(f"/api/v1/printers/{printer.id}/refresh-status")
  482. assert response.status_code == 200
  483. assert response.json()["status"] == "refresh_requested"
  484. mock_pm.request_status_update.assert_called_once_with(printer.id)
  485. # ========================================================================
  486. # Current print user endpoint (Issue #206)
  487. # ========================================================================
  488. @pytest.mark.asyncio
  489. @pytest.mark.integration
  490. async def test_get_current_print_user_not_found(self, async_client: AsyncClient):
  491. """Verify 404 for non-existent printer."""
  492. response = await async_client.get("/api/v1/printers/99999/current-print-user")
  493. assert response.status_code == 404
  494. @pytest.mark.asyncio
  495. @pytest.mark.integration
  496. async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):
  497. """Verify empty object returned when no user is tracked."""
  498. printer = await printer_factory(name="Test Printer")
  499. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  500. mock_pm.get_current_print_user.return_value = None
  501. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  502. assert response.status_code == 200
  503. assert response.json() == {}
  504. @pytest.mark.asyncio
  505. @pytest.mark.integration
  506. async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):
  507. """Verify user info is returned when tracked."""
  508. printer = await printer_factory(name="Test Printer")
  509. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  510. mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser"}
  511. response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
  512. assert response.status_code == 200
  513. result = response.json()
  514. assert result["user_id"] == 42
  515. assert result["username"] == "testuser"
  516. class TestPrintControlAPI:
  517. """Integration tests for print control endpoints (stop, pause, resume)."""
  518. # ========================================================================
  519. # Stop print endpoint
  520. # ========================================================================
  521. @pytest.mark.asyncio
  522. @pytest.mark.integration
  523. async def test_stop_print_not_found(self, async_client: AsyncClient):
  524. """Verify 404 for non-existent printer."""
  525. response = await async_client.post("/api/v1/printers/99999/print/stop")
  526. assert response.status_code == 404
  527. @pytest.mark.asyncio
  528. @pytest.mark.integration
  529. async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):
  530. """Verify error when printer is not connected."""
  531. printer = await printer_factory(name="Disconnected Printer")
  532. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  533. mock_pm.get_client.return_value = None
  534. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  535. assert response.status_code == 400
  536. assert "not connected" in response.json()["detail"].lower()
  537. @pytest.mark.asyncio
  538. @pytest.mark.integration
  539. async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):
  540. """Verify successful stop print request."""
  541. printer = await printer_factory(name="Printing Printer")
  542. mock_client = MagicMock()
  543. mock_client.stop_print.return_value = True
  544. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  545. mock_pm.get_client.return_value = mock_client
  546. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/stop")
  547. assert response.status_code == 200
  548. assert response.json()["success"] is True
  549. mock_client.stop_print.assert_called_once()
  550. # ========================================================================
  551. # Pause print endpoint
  552. # ========================================================================
  553. @pytest.mark.asyncio
  554. @pytest.mark.integration
  555. async def test_pause_print_not_found(self, async_client: AsyncClient):
  556. """Verify 404 for non-existent printer."""
  557. response = await async_client.post("/api/v1/printers/99999/print/pause")
  558. assert response.status_code == 404
  559. @pytest.mark.asyncio
  560. @pytest.mark.integration
  561. async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):
  562. """Verify error when printer is not connected."""
  563. printer = await printer_factory(name="Disconnected Printer")
  564. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  565. mock_pm.get_client.return_value = None
  566. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  567. assert response.status_code == 400
  568. assert "not connected" in response.json()["detail"].lower()
  569. @pytest.mark.asyncio
  570. @pytest.mark.integration
  571. async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):
  572. """Verify successful pause print request."""
  573. printer = await printer_factory(name="Printing Printer")
  574. mock_client = MagicMock()
  575. mock_client.pause_print.return_value = True
  576. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  577. mock_pm.get_client.return_value = mock_client
  578. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/pause")
  579. assert response.status_code == 200
  580. assert response.json()["success"] is True
  581. mock_client.pause_print.assert_called_once()
  582. # ========================================================================
  583. # Resume print endpoint
  584. # ========================================================================
  585. @pytest.mark.asyncio
  586. @pytest.mark.integration
  587. async def test_resume_print_not_found(self, async_client: AsyncClient):
  588. """Verify 404 for non-existent printer."""
  589. response = await async_client.post("/api/v1/printers/99999/print/resume")
  590. assert response.status_code == 404
  591. @pytest.mark.asyncio
  592. @pytest.mark.integration
  593. async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):
  594. """Verify error when printer is not connected."""
  595. printer = await printer_factory(name="Disconnected Printer")
  596. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  597. mock_pm.get_client.return_value = None
  598. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  599. assert response.status_code == 400
  600. assert "not connected" in response.json()["detail"].lower()
  601. @pytest.mark.asyncio
  602. @pytest.mark.integration
  603. async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):
  604. """Verify successful resume print request."""
  605. printer = await printer_factory(name="Paused Printer")
  606. mock_client = MagicMock()
  607. mock_client.resume_print.return_value = True
  608. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  609. mock_pm.get_client.return_value = mock_client
  610. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/resume")
  611. assert response.status_code == 200
  612. assert response.json()["success"] is True
  613. mock_client.resume_print.assert_called_once()
  614. class TestAMSRefreshAPI:
  615. """Integration tests for AMS slot refresh endpoint."""
  616. @pytest.mark.asyncio
  617. @pytest.mark.integration
  618. async def test_ams_refresh_not_found(self, async_client: AsyncClient):
  619. """Verify 404 for non-existent printer."""
  620. response = await async_client.post("/api/v1/printers/99999/ams/0/slot/0/refresh")
  621. assert response.status_code == 404
  622. @pytest.mark.asyncio
  623. @pytest.mark.integration
  624. async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):
  625. """Verify error when printer is not connected."""
  626. printer = await printer_factory(name="Disconnected Printer")
  627. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  628. mock_pm.get_client.return_value = None
  629. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  630. assert response.status_code == 400
  631. assert "not connected" in response.json()["detail"].lower()
  632. @pytest.mark.asyncio
  633. @pytest.mark.integration
  634. async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):
  635. """Verify successful AMS refresh request."""
  636. printer = await printer_factory(name="Printer with AMS")
  637. mock_client = MagicMock()
  638. mock_client.ams_refresh_tray.return_value = (True, "Refreshing AMS 0 tray 1")
  639. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  640. mock_pm.get_client.return_value = mock_client
  641. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh")
  642. assert response.status_code == 200
  643. result = response.json()
  644. assert result["success"] is True
  645. mock_client.ams_refresh_tray.assert_called_once_with(0, 1)
  646. @pytest.mark.asyncio
  647. @pytest.mark.integration
  648. async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):
  649. """Verify error when filament is loaded (can't refresh while loaded)."""
  650. printer = await printer_factory(name="Printer with AMS")
  651. mock_client = MagicMock()
  652. mock_client.ams_refresh_tray.return_value = (False, "Please unload filament first")
  653. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  654. mock_pm.get_client.return_value = mock_client
  655. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh")
  656. assert response.status_code == 400
  657. assert "unload" in response.json()["detail"].lower()
  658. class TestAMSLoadUnloadAPI:
  659. """Integration tests for AMS load / unload endpoints (#891)."""
  660. # ── load ─────────────────────────────────────────────────────────────────
  661. @pytest.mark.asyncio
  662. @pytest.mark.integration
  663. async def test_load_invalid_tray_id(self, async_client: AsyncClient, printer_factory):
  664. """tray_id outside {0..15, 254, 255} is rejected."""
  665. printer = await printer_factory(name="P")
  666. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=99")
  667. assert response.status_code == 400
  668. @pytest.mark.asyncio
  669. @pytest.mark.integration
  670. async def test_load_not_found(self, async_client: AsyncClient):
  671. response = await async_client.post("/api/v1/printers/99999/ams/load?tray_id=0")
  672. assert response.status_code == 404
  673. @pytest.mark.asyncio
  674. @pytest.mark.integration
  675. async def test_load_not_connected(self, async_client: AsyncClient, printer_factory):
  676. printer = await printer_factory(name="Disconnected")
  677. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  678. mock_pm.get_client.return_value = None
  679. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  680. assert response.status_code == 400
  681. assert "not connected" in response.json()["detail"].lower()
  682. @pytest.mark.asyncio
  683. @pytest.mark.integration
  684. async def test_load_ams_slot_success(self, async_client: AsyncClient, printer_factory):
  685. """tray_id=5 → AMS 1 slot 2 (1-indexed in the message)."""
  686. printer = await printer_factory(name="P")
  687. mock_client = MagicMock()
  688. mock_client.ams_load_filament.return_value = True
  689. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  690. mock_pm.get_client.return_value = mock_client
  691. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=5")
  692. assert response.status_code == 200
  693. mock_client.ams_load_filament.assert_called_once_with(5)
  694. assert "AMS 1" in response.json()["message"]
  695. @pytest.mark.asyncio
  696. @pytest.mark.integration
  697. async def test_load_external_left_success(self, async_client: AsyncClient, printer_factory):
  698. """tray_id=254 → external spool / Ext-L."""
  699. printer = await printer_factory(name="P")
  700. mock_client = MagicMock()
  701. mock_client.ams_load_filament.return_value = True
  702. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  703. mock_pm.get_client.return_value = mock_client
  704. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=254")
  705. assert response.status_code == 200
  706. mock_client.ams_load_filament.assert_called_once_with(254)
  707. assert "external" in response.json()["message"].lower()
  708. @pytest.mark.asyncio
  709. @pytest.mark.integration
  710. async def test_load_external_right_success(self, async_client: AsyncClient, printer_factory):
  711. """tray_id=255 → Ext-R on dual-nozzle H2D."""
  712. printer = await printer_factory(name="H2D")
  713. mock_client = MagicMock()
  714. mock_client.ams_load_filament.return_value = True
  715. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  716. mock_pm.get_client.return_value = mock_client
  717. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=255")
  718. assert response.status_code == 200
  719. mock_client.ams_load_filament.assert_called_once_with(255)
  720. assert "Ext-R" in response.json()["message"]
  721. @pytest.mark.asyncio
  722. @pytest.mark.integration
  723. async def test_load_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  724. printer = await printer_factory(name="P")
  725. mock_client = MagicMock()
  726. mock_client.ams_load_filament.return_value = False
  727. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  728. mock_pm.get_client.return_value = mock_client
  729. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=0")
  730. assert response.status_code == 500
  731. assert "failed" in response.json()["detail"].lower()
  732. # ── unload ───────────────────────────────────────────────────────────────
  733. @pytest.mark.asyncio
  734. @pytest.mark.integration
  735. async def test_unload_not_found(self, async_client: AsyncClient):
  736. response = await async_client.post("/api/v1/printers/99999/ams/unload")
  737. assert response.status_code == 404
  738. @pytest.mark.asyncio
  739. @pytest.mark.integration
  740. async def test_unload_not_connected(self, async_client: AsyncClient, printer_factory):
  741. printer = await printer_factory(name="Disconnected")
  742. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  743. mock_pm.get_client.return_value = None
  744. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  745. assert response.status_code == 400
  746. assert "not connected" in response.json()["detail"].lower()
  747. @pytest.mark.asyncio
  748. @pytest.mark.integration
  749. async def test_unload_success(self, async_client: AsyncClient, printer_factory):
  750. printer = await printer_factory(name="P")
  751. mock_client = MagicMock()
  752. mock_client.ams_unload_filament.return_value = True
  753. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  754. mock_pm.get_client.return_value = mock_client
  755. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  756. assert response.status_code == 200
  757. mock_client.ams_unload_filament.assert_called_once_with()
  758. assert response.json()["success"] is True
  759. @pytest.mark.asyncio
  760. @pytest.mark.integration
  761. async def test_unload_mqtt_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  762. printer = await printer_factory(name="P")
  763. mock_client = MagicMock()
  764. mock_client.ams_unload_filament.return_value = False
  765. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  766. mock_pm.get_client.return_value = mock_client
  767. response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/unload")
  768. assert response.status_code == 500
  769. assert "failed" in response.json()["detail"].lower()
  770. class TestConfigureAMSSlotAPI:
  771. """Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
  772. @pytest.mark.asyncio
  773. @pytest.mark.integration
  774. async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
  775. """Verify error when printer is not connected."""
  776. printer = await printer_factory(name="Disconnected")
  777. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  778. mock_pm.get_client.return_value = None
  779. response = await async_client.post(
  780. f"/api/v1/printers/{printer.id}/slots/0/0/configure",
  781. params={
  782. "tray_info_idx": "GFL99",
  783. "tray_type": "PLA",
  784. "tray_sub_brands": "PLA Basic",
  785. "tray_color": "FF0000FF",
  786. "nozzle_temp_min": 190,
  787. "nozzle_temp_max": 230,
  788. },
  789. )
  790. assert response.status_code == 400
  791. assert "not connected" in response.json()["detail"].lower()
  792. @pytest.mark.asyncio
  793. @pytest.mark.integration
  794. async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
  795. """Standard Bambu GF* filament IDs are sent as-is."""
  796. printer = await printer_factory(name="H2D")
  797. mock_client = MagicMock()
  798. mock_client.ams_set_filament_setting.return_value = True
  799. mock_client.extrusion_cali_sel.return_value = True
  800. mock_client.request_status_update.return_value = True
  801. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  802. mock_pm.get_client.return_value = mock_client
  803. mock_pm.get_status.return_value = None # No existing state
  804. response = await async_client.post(
  805. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  806. params={
  807. "tray_info_idx": "GFL05",
  808. "tray_type": "PLA",
  809. "tray_sub_brands": "PLA Basic",
  810. "tray_color": "FFFFFFFF",
  811. "nozzle_temp_min": 190,
  812. "nozzle_temp_max": 230,
  813. },
  814. )
  815. assert response.status_code == 200
  816. call_kwargs = mock_client.ams_set_filament_setting.call_args
  817. assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
  818. @pytest.mark.asyncio
  819. @pytest.mark.integration
  820. async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
  821. """PFUS* cloud-synced custom preset IDs are sent to the printer."""
  822. printer = await printer_factory(name="H2D")
  823. mock_client = MagicMock()
  824. mock_client.ams_set_filament_setting.return_value = True
  825. mock_client.extrusion_cali_sel.return_value = True
  826. mock_client.request_status_update.return_value = True
  827. mock_status = MagicMock()
  828. mock_status.raw_data = {"ams": {"ams": []}} # No existing tray data
  829. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  830. mock_pm.get_client.return_value = mock_client
  831. mock_pm.get_status.return_value = mock_status
  832. response = await async_client.post(
  833. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  834. params={
  835. "tray_info_idx": "PFUS9ac902733670a9",
  836. "tray_type": "PLA",
  837. "tray_sub_brands": "Devil Design PLA",
  838. "tray_color": "FF0000FF",
  839. "nozzle_temp_min": 190,
  840. "nozzle_temp_max": 230,
  841. },
  842. )
  843. assert response.status_code == 200
  844. call_kwargs = mock_client.ams_set_filament_setting.call_args
  845. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  846. @pytest.mark.asyncio
  847. @pytest.mark.integration
  848. async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):
  849. """Provided PFUS* preset takes priority over slot's existing preset."""
  850. printer = await printer_factory(name="H2D")
  851. mock_client = MagicMock()
  852. mock_client.ams_set_filament_setting.return_value = True
  853. mock_client.extrusion_cali_sel.return_value = True
  854. mock_client.request_status_update.return_value = True
  855. # Simulate slot already configured by slicer with cloud-synced preset
  856. mock_status = MagicMock()
  857. mock_status.raw_data = {
  858. "ams": {
  859. "ams": [
  860. {
  861. "id": 2,
  862. "tray": [
  863. {
  864. "id": 3,
  865. "tray_info_idx": "P4d64437",
  866. "tray_type": "PLA",
  867. "tray_color": "FF0000FF",
  868. }
  869. ],
  870. }
  871. ]
  872. }
  873. }
  874. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  875. mock_pm.get_client.return_value = mock_client
  876. mock_pm.get_status.return_value = mock_status
  877. response = await async_client.post(
  878. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  879. params={
  880. "tray_info_idx": "PFUS9ac902733670a9",
  881. "tray_type": "PLA",
  882. "tray_sub_brands": "Devil Design PLA",
  883. "tray_color": "FF0000FF",
  884. "nozzle_temp_min": 190,
  885. "nozzle_temp_max": 230,
  886. },
  887. )
  888. assert response.status_code == 200
  889. call_kwargs = mock_client.ams_set_filament_setting.call_args
  890. # Provided preset wins over slot's existing one
  891. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  892. @pytest.mark.asyncio
  893. @pytest.mark.integration
  894. async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):
  895. """Provided PFUS* preset is used even when slot has a different material."""
  896. printer = await printer_factory(name="H2D")
  897. mock_client = MagicMock()
  898. mock_client.ams_set_filament_setting.return_value = True
  899. mock_client.extrusion_cali_sel.return_value = True
  900. mock_client.request_status_update.return_value = True
  901. # Slot currently has PETG but user is configuring PLA
  902. mock_status = MagicMock()
  903. mock_status.raw_data = {
  904. "ams": {
  905. "ams": [
  906. {
  907. "id": 2,
  908. "tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
  909. }
  910. ]
  911. }
  912. }
  913. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  914. mock_pm.get_client.return_value = mock_client
  915. mock_pm.get_status.return_value = mock_status
  916. response = await async_client.post(
  917. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  918. params={
  919. "tray_info_idx": "PFUS9ac902733670a9",
  920. "tray_type": "PLA",
  921. "tray_sub_brands": "Devil Design PLA",
  922. "tray_color": "FF0000FF",
  923. "nozzle_temp_min": 190,
  924. "nozzle_temp_max": 230,
  925. },
  926. )
  927. assert response.status_code == 200
  928. call_kwargs = mock_client.ams_set_filament_setting.call_args
  929. # Provided preset wins — slot's material is irrelevant
  930. assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
  931. @pytest.mark.asyncio
  932. @pytest.mark.integration
  933. async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
  934. """Empty tray_info_idx (local preset) is replaced with generic."""
  935. printer = await printer_factory(name="H2D")
  936. mock_client = MagicMock()
  937. mock_client.ams_set_filament_setting.return_value = True
  938. mock_client.extrusion_cali_sel.return_value = True
  939. mock_client.request_status_update.return_value = True
  940. mock_status = MagicMock()
  941. mock_status.raw_data = {"ams": {"ams": []}}
  942. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  943. mock_pm.get_client.return_value = mock_client
  944. mock_pm.get_status.return_value = mock_status
  945. response = await async_client.post(
  946. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  947. params={
  948. "tray_info_idx": "",
  949. "tray_type": "PETG",
  950. "tray_sub_brands": "PETG Basic",
  951. "tray_color": "FFFFFFFF",
  952. "nozzle_temp_min": 220,
  953. "nozzle_temp_max": 260,
  954. },
  955. )
  956. assert response.status_code == 200
  957. call_kwargs = mock_client.ams_set_filament_setting.call_args
  958. assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
  959. @pytest.mark.asyncio
  960. @pytest.mark.integration
  961. async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):
  962. """Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.
  963. Pins the end-to-end contract the frontend #1053 fix relies on: when the
  964. user configures a slot with a custom cloud preset whose cloud detail
  965. has filament_id=null, the frontend sends the setting_id in BOTH fields
  966. and the backend must not collapse either to a generic GF* ID.
  967. """
  968. printer = await printer_factory(name="H2D")
  969. mock_client = MagicMock()
  970. mock_client.ams_set_filament_setting.return_value = True
  971. mock_client.extrusion_cali_sel.return_value = True
  972. mock_client.request_status_update.return_value = True
  973. mock_status = MagicMock()
  974. mock_status.raw_data = {"ams": {"ams": []}}
  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 = mock_status
  978. response = await async_client.post(
  979. f"/api/v1/printers/{printer.id}/slots/128/0/configure",
  980. params={
  981. "tray_info_idx": "PFUSa8fb76f9733e3c",
  982. "tray_type": "ABS",
  983. "tray_sub_brands": "Sting3D ABS",
  984. "tray_color": "000000FF",
  985. "nozzle_temp_min": 240,
  986. "nozzle_temp_max": 280,
  987. "setting_id": "PFUSa8fb76f9733e3c",
  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"] == "PFUSa8fb76f9733e3c"
  993. assert call_kwargs.kwargs["setting_id"] == "PFUSa8fb76f9733e3c"
  994. # Explicitly assert no generic-collapse happened for this HT slot.
  995. assert call_kwargs.kwargs["tray_info_idx"] != "GFB99"
  996. class TestSkipObjectsAPI:
  997. """Integration tests for skip objects endpoints."""
  998. # ========================================================================
  999. # Get printable objects endpoint
  1000. # ========================================================================
  1001. @pytest.mark.asyncio
  1002. @pytest.mark.integration
  1003. async def test_get_objects_not_found(self, async_client: AsyncClient):
  1004. """Verify 404 for non-existent printer."""
  1005. response = await async_client.get("/api/v1/printers/99999/print/objects")
  1006. assert response.status_code == 404
  1007. @pytest.mark.asyncio
  1008. @pytest.mark.integration
  1009. async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  1010. """Verify error when printer is not connected."""
  1011. printer = await printer_factory(name="Disconnected Printer")
  1012. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1013. mock_pm.get_client.return_value = None
  1014. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1015. assert response.status_code == 400
  1016. assert "not connected" in response.json()["detail"].lower()
  1017. @pytest.mark.asyncio
  1018. @pytest.mark.integration
  1019. async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):
  1020. """Verify empty objects list when no print is active."""
  1021. printer = await printer_factory(name="Idle Printer")
  1022. mock_client = MagicMock()
  1023. mock_client.state.printable_objects = {}
  1024. mock_client.state.skipped_objects = []
  1025. mock_client.state.state = "IDLE"
  1026. mock_client.state.subtask_name = None # Prevent FTP download attempt
  1027. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1028. mock_pm.get_client.return_value = mock_client
  1029. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1030. assert response.status_code == 200
  1031. result = response.json()
  1032. assert result["objects"] == []
  1033. assert result["total"] == 0
  1034. assert result["skipped_count"] == 0
  1035. assert result["is_printing"] is False
  1036. @pytest.mark.asyncio
  1037. @pytest.mark.integration
  1038. async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):
  1039. """Verify objects list when print is active."""
  1040. printer = await printer_factory(name="Printing Printer")
  1041. mock_client = MagicMock()
  1042. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  1043. mock_client.state.skipped_objects = [200]
  1044. mock_client.state.state = "RUNNING"
  1045. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1046. mock_pm.get_client.return_value = mock_client
  1047. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1048. assert response.status_code == 200
  1049. result = response.json()
  1050. assert result["total"] == 3
  1051. assert result["skipped_count"] == 1
  1052. assert result["is_printing"] is True
  1053. # Check objects have correct structure
  1054. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  1055. assert objects_by_id[100]["name"] == "Part A"
  1056. assert objects_by_id[100]["skipped"] is False
  1057. assert objects_by_id[200]["name"] == "Part B"
  1058. assert objects_by_id[200]["skipped"] is True
  1059. assert objects_by_id[300]["name"] == "Part C"
  1060. assert objects_by_id[300]["skipped"] is False
  1061. # ========================================================================
  1062. # Skip objects endpoint
  1063. # ========================================================================
  1064. @pytest.mark.asyncio
  1065. @pytest.mark.integration
  1066. async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):
  1067. """Verify objects list includes position data when available."""
  1068. printer = await printer_factory(name="Printing Printer")
  1069. # New format with position data
  1070. mock_client = MagicMock()
  1071. mock_client.state.printable_objects = {
  1072. 100: {"name": "Part A", "x": 50.0, "y": 100.0},
  1073. 200: {"name": "Part B", "x": 150.0, "y": 100.0},
  1074. }
  1075. mock_client.state.skipped_objects = []
  1076. mock_client.state.state = "RUNNING"
  1077. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1078. mock_pm.get_client.return_value = mock_client
  1079. response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
  1080. assert response.status_code == 200
  1081. result = response.json()
  1082. assert result["total"] == 2
  1083. # Check objects have position data
  1084. objects_by_id = {obj["id"]: obj for obj in result["objects"]}
  1085. assert objects_by_id[100]["name"] == "Part A"
  1086. assert objects_by_id[100]["x"] == 50.0
  1087. assert objects_by_id[100]["y"] == 100.0
  1088. assert objects_by_id[200]["name"] == "Part B"
  1089. assert objects_by_id[200]["x"] == 150.0
  1090. assert objects_by_id[200]["y"] == 100.0
  1091. # ========================================================================
  1092. # Skip objects endpoint
  1093. # ========================================================================
  1094. @pytest.mark.asyncio
  1095. @pytest.mark.integration
  1096. async def test_skip_objects_not_found(self, async_client: AsyncClient):
  1097. """Verify 404 for non-existent printer."""
  1098. response = await async_client.post("/api/v1/printers/99999/print/skip-objects", json=[100])
  1099. assert response.status_code == 404
  1100. @pytest.mark.asyncio
  1101. @pytest.mark.integration
  1102. async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):
  1103. """Verify error when printer is not connected."""
  1104. printer = await printer_factory(name="Disconnected Printer")
  1105. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1106. mock_pm.get_client.return_value = None
  1107. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1108. assert response.status_code == 400
  1109. assert "not connected" in response.json()["detail"].lower()
  1110. @pytest.mark.asyncio
  1111. @pytest.mark.integration
  1112. async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):
  1113. """Verify error when no object IDs provided."""
  1114. printer = await printer_factory(name="Printing Printer")
  1115. mock_client = MagicMock()
  1116. mock_client.state.printable_objects = {100: "Part A"}
  1117. mock_client.state.skipped_objects = []
  1118. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1119. mock_pm.get_client.return_value = mock_client
  1120. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[])
  1121. assert response.status_code == 400
  1122. assert "no object" in response.json()["detail"].lower()
  1123. @pytest.mark.asyncio
  1124. @pytest.mark.integration
  1125. async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):
  1126. """Verify error when object ID doesn't exist."""
  1127. printer = await printer_factory(name="Printing Printer")
  1128. mock_client = MagicMock()
  1129. mock_client.state.printable_objects = {100: "Part A"}
  1130. mock_client.state.skipped_objects = []
  1131. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1132. mock_pm.get_client.return_value = mock_client
  1133. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[999])
  1134. assert response.status_code == 400
  1135. assert "invalid" in response.json()["detail"].lower()
  1136. @pytest.mark.asyncio
  1137. @pytest.mark.integration
  1138. async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):
  1139. """Verify successful skip objects request."""
  1140. printer = await printer_factory(name="Printing Printer")
  1141. mock_client = MagicMock()
  1142. mock_client.state.printable_objects = {100: "Part A", 200: "Part B"}
  1143. mock_client.state.skipped_objects = []
  1144. mock_client.skip_objects.return_value = True
  1145. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1146. mock_pm.get_client.return_value = mock_client
  1147. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100])
  1148. assert response.status_code == 200
  1149. result = response.json()
  1150. assert result["success"] is True
  1151. assert 100 in result["skipped_objects"]
  1152. mock_client.skip_objects.assert_called_once_with([100])
  1153. @pytest.mark.asyncio
  1154. @pytest.mark.integration
  1155. async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):
  1156. """Verify skipping multiple objects at once."""
  1157. printer = await printer_factory(name="Printing Printer")
  1158. mock_client = MagicMock()
  1159. mock_client.state.printable_objects = {100: "Part A", 200: "Part B", 300: "Part C"}
  1160. mock_client.state.skipped_objects = []
  1161. mock_client.skip_objects.return_value = True
  1162. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1163. mock_pm.get_client.return_value = mock_client
  1164. response = await async_client.post(f"/api/v1/printers/{printer.id}/print/skip-objects", json=[100, 200])
  1165. assert response.status_code == 200
  1166. result = response.json()
  1167. assert result["success"] is True
  1168. assert 100 in result["skipped_objects"]
  1169. assert 200 in result["skipped_objects"]
  1170. mock_client.skip_objects.assert_called_once_with([100, 200])
  1171. class TestChamberLightAPI:
  1172. """Integration tests for chamber light control endpoint."""
  1173. @pytest.mark.asyncio
  1174. @pytest.mark.integration
  1175. async def test_chamber_light_not_found(self, async_client: AsyncClient):
  1176. """Verify 404 for non-existent printer."""
  1177. response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true")
  1178. assert response.status_code == 404
  1179. @pytest.mark.asyncio
  1180. @pytest.mark.integration
  1181. async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):
  1182. """Verify error when printer is not connected."""
  1183. printer = await printer_factory(name="Disconnected Printer")
  1184. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1185. mock_pm.get_client.return_value = None
  1186. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1187. assert response.status_code == 400
  1188. assert "not connected" in response.json()["detail"].lower()
  1189. @pytest.mark.asyncio
  1190. @pytest.mark.integration
  1191. async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):
  1192. """Verify successful chamber light on request."""
  1193. printer = await printer_factory(name="Test Printer")
  1194. mock_client = MagicMock()
  1195. mock_client.set_chamber_light.return_value = True
  1196. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1197. mock_pm.get_client.return_value = mock_client
  1198. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1199. assert response.status_code == 200
  1200. result = response.json()
  1201. assert result["success"] is True
  1202. assert "on" in result["message"].lower()
  1203. mock_client.set_chamber_light.assert_called_once_with(True)
  1204. @pytest.mark.asyncio
  1205. @pytest.mark.integration
  1206. async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):
  1207. """Verify successful chamber light off request."""
  1208. printer = await printer_factory(name="Test Printer")
  1209. mock_client = MagicMock()
  1210. mock_client.set_chamber_light.return_value = True
  1211. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1212. mock_pm.get_client.return_value = mock_client
  1213. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false")
  1214. assert response.status_code == 200
  1215. result = response.json()
  1216. assert result["success"] is True
  1217. assert "off" in result["message"].lower()
  1218. mock_client.set_chamber_light.assert_called_once_with(False)
  1219. @pytest.mark.asyncio
  1220. @pytest.mark.integration
  1221. async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):
  1222. """Verify error handling when chamber light control fails."""
  1223. printer = await printer_factory(name="Test Printer")
  1224. mock_client = MagicMock()
  1225. mock_client.set_chamber_light.return_value = False
  1226. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1227. mock_pm.get_client.return_value = mock_client
  1228. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  1229. assert response.status_code == 500
  1230. assert "failed" in response.json()["detail"].lower()
  1231. class TestAirductModeAPI:
  1232. """Integration tests for the airduct mode endpoint (P2S/H2*)."""
  1233. @pytest.mark.asyncio
  1234. @pytest.mark.integration
  1235. async def test_invalid_mode_rejected(self, async_client: AsyncClient, printer_factory):
  1236. printer = await printer_factory(name="P", model="P2S")
  1237. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=foo")
  1238. assert response.status_code == 400
  1239. @pytest.mark.asyncio
  1240. @pytest.mark.integration
  1241. async def test_not_connected(self, async_client: AsyncClient, printer_factory):
  1242. printer = await printer_factory(name="P", model="P2S")
  1243. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1244. mock_pm.get_client.return_value = None
  1245. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1246. assert response.status_code == 400
  1247. @pytest.mark.asyncio
  1248. @pytest.mark.integration
  1249. async def test_cooling_success(self, async_client: AsyncClient, printer_factory):
  1250. printer = await printer_factory(name="P", model="P2S")
  1251. mock_client = MagicMock()
  1252. mock_client.set_airduct_mode.return_value = True
  1253. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1254. mock_pm.get_client.return_value = mock_client
  1255. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling")
  1256. assert response.status_code == 200
  1257. assert response.json()["success"] is True
  1258. mock_client.set_airduct_mode.assert_called_once_with("cooling")
  1259. @pytest.mark.asyncio
  1260. @pytest.mark.integration
  1261. async def test_heating_failure_returns_500(self, async_client: AsyncClient, printer_factory):
  1262. printer = await printer_factory(name="P", model="P2S")
  1263. mock_client = MagicMock()
  1264. mock_client.set_airduct_mode.return_value = False
  1265. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1266. mock_pm.get_client.return_value = mock_client
  1267. response = await async_client.post(f"/api/v1/printers/{printer.id}/airduct-mode?mode=heating")
  1268. assert response.status_code == 500
  1269. class TestClearHMSErrorsAPI:
  1270. """Integration tests for clear HMS errors endpoint."""
  1271. @pytest.mark.asyncio
  1272. @pytest.mark.integration
  1273. async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):
  1274. """Verify 404 for non-existent printer."""
  1275. response = await async_client.post("/api/v1/printers/99999/hms/clear")
  1276. assert response.status_code == 404
  1277. @pytest.mark.asyncio
  1278. @pytest.mark.integration
  1279. async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):
  1280. """Verify error when printer is not connected."""
  1281. printer = await printer_factory(name="Disconnected Printer")
  1282. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1283. mock_pm.get_client.return_value = None
  1284. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1285. assert response.status_code == 400
  1286. assert "not connected" in response.json()["detail"].lower()
  1287. @pytest.mark.asyncio
  1288. @pytest.mark.integration
  1289. async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):
  1290. """Verify successful clear HMS errors request."""
  1291. printer = await printer_factory(name="Test Printer")
  1292. mock_client = MagicMock()
  1293. mock_client.clear_hms_errors.return_value = True
  1294. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1295. mock_pm.get_client.return_value = mock_client
  1296. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1297. assert response.status_code == 200
  1298. result = response.json()
  1299. assert result["success"] is True
  1300. assert "cleared" in result["message"].lower()
  1301. mock_client.clear_hms_errors.assert_called_once()
  1302. @pytest.mark.asyncio
  1303. @pytest.mark.integration
  1304. async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):
  1305. """Verify error handling when clear HMS errors fails."""
  1306. printer = await printer_factory(name="Test Printer")
  1307. mock_client = MagicMock()
  1308. mock_client.clear_hms_errors.return_value = False
  1309. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1310. mock_pm.get_client.return_value = mock_client
  1311. response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/clear")
  1312. assert response.status_code == 500
  1313. assert "failed" in response.json()["detail"].lower()
  1314. def _build_h2d_state(*, ams_id: int = 0, tray_id: int = 2, cali_idx: int = 5):
  1315. """Build a MagicMock PrinterState for an H2D printer with a single BL spool tray.
  1316. Used by both TestApplyPaAfterRefresh (Phase 13 P13-T-BE-1) and the K-profile
  1317. persistence tests below. The tray data passes is_bambu_tag (32-char non-zero
  1318. tray_uuid + non-empty tray_info_idx).
  1319. """
  1320. nozzle = MagicMock(nozzle_diameter="0.4")
  1321. state = MagicMock()
  1322. state.nozzles = [nozzle]
  1323. state.ams_extruder_map = {"0": 0}
  1324. state.raw_data = {
  1325. "ams": [
  1326. {
  1327. "id": ams_id,
  1328. "tray": [
  1329. {
  1330. "id": tray_id,
  1331. "tray_type": "PLA",
  1332. "tag_uid": "AABBCC1122334400",
  1333. "tray_uuid": "11223344556677880011223344556677",
  1334. "tray_info_idx": "GFL05",
  1335. "cali_idx": cali_idx,
  1336. }
  1337. ],
  1338. }
  1339. ]
  1340. }
  1341. return state
  1342. def _patch_async_session_to(db_session):
  1343. """Patch backend.app.core.database.async_session so calls inside the function
  1344. under test reuse the test fixture's db_session.
  1345. `_apply_pa_after_refresh` lazy-imports `from backend.app.core.database import
  1346. async_session` at runtime (line 2849). When we patch the source module
  1347. before the call, the lazy import picks up the patched object.
  1348. Returns the patch context manager; use as `with _patch_async_session_to(db_session):`.
  1349. Pattern verified against test_print_lifecycle.py:38-42.
  1350. """
  1351. cm = AsyncMock()
  1352. cm.__aenter__ = AsyncMock(return_value=db_session)
  1353. cm.__aexit__ = AsyncMock(return_value=None)
  1354. return patch("backend.app.core.database.async_session", return_value=cm)
  1355. class TestApplyPaAfterRefresh:
  1356. """Phase 13 P13-T-BE-1: _apply_pa_after_refresh K-profile cascade.
  1357. Verifies the 3-stage cascade (local SpoolKProfile → Spoolman SpoolmanKProfile
  1358. → live tray.cali_idx fallback) and the Bug A regression (kp.extruder, not
  1359. kp.extruder_id, after the Phase 13 P13-2a fix).
  1360. `_apply_pa_after_refresh` is a free function spawned via asyncio.create_task
  1361. from the /ams-refresh endpoint. Tests call it directly because awaiting the
  1362. spawned task in an HTTP test would require sleeping past the 5-second guard
  1363. that delays MQTT until RFID re-read finishes.
  1364. """
  1365. @pytest.mark.asyncio
  1366. @pytest.mark.integration
  1367. async def test_local_kp_match_sends_stored_cali_idx(self, db_session, printer_factory):
  1368. """Local SpoolAssignment + matching SpoolKProfile → stored cali_idx wins."""
  1369. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1370. from backend.app.models.spool import Spool
  1371. from backend.app.models.spool_assignment import SpoolAssignment
  1372. from backend.app.models.spool_k_profile import SpoolKProfile
  1373. printer = await printer_factory()
  1374. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  1375. db_session.add(spool)
  1376. await db_session.flush()
  1377. db_session.add(
  1378. SpoolAssignment(
  1379. spool_id=spool.id,
  1380. printer_id=printer.id,
  1381. ams_id=0,
  1382. tray_id=2,
  1383. )
  1384. )
  1385. db_session.add(
  1386. SpoolKProfile(
  1387. spool_id=spool.id,
  1388. printer_id=printer.id,
  1389. extruder=0,
  1390. nozzle_diameter="0.4",
  1391. k_value=0.025,
  1392. cali_idx=42,
  1393. )
  1394. )
  1395. await db_session.commit()
  1396. mock_client = MagicMock()
  1397. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1398. state = _build_h2d_state(cali_idx=5)
  1399. with (
  1400. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1401. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1402. _patch_async_session_to(db_session),
  1403. ):
  1404. mock_pm.get_client.return_value = mock_client
  1405. mock_pm.get_status.return_value = state
  1406. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1407. mock_client.extrusion_cali_sel.assert_called_once()
  1408. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1409. assert kwargs["cali_idx"] == 42 # stored profile, not 5 (live)
  1410. @pytest.mark.asyncio
  1411. @pytest.mark.integration
  1412. async def test_local_no_kp_uses_live_cali_idx(self, db_session, printer_factory):
  1413. """Local SpoolAssignment but no matching SpoolKProfile → live cali_idx (Stage 3)."""
  1414. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1415. from backend.app.models.spool import Spool
  1416. from backend.app.models.spool_assignment import SpoolAssignment
  1417. printer = await printer_factory()
  1418. spool = Spool(material="PLA")
  1419. db_session.add(spool)
  1420. await db_session.flush()
  1421. db_session.add(
  1422. SpoolAssignment(
  1423. spool_id=spool.id,
  1424. printer_id=printer.id,
  1425. ams_id=0,
  1426. tray_id=2,
  1427. )
  1428. )
  1429. await db_session.commit()
  1430. mock_client = MagicMock()
  1431. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1432. state = _build_h2d_state(cali_idx=5)
  1433. with (
  1434. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1435. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1436. _patch_async_session_to(db_session),
  1437. ):
  1438. mock_pm.get_client.return_value = mock_client
  1439. mock_pm.get_status.return_value = state
  1440. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1441. mock_client.extrusion_cali_sel.assert_called_once()
  1442. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1443. assert kwargs["cali_idx"] == 5 # live tray.cali_idx fallback
  1444. @pytest.mark.asyncio
  1445. @pytest.mark.integration
  1446. async def test_spoolman_kp_when_no_local(self, db_session, printer_factory):
  1447. """No local assignment + Spoolman SlotAssignment + SpoolmanKProfile → Spoolman cali_idx."""
  1448. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1449. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  1450. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1451. printer = await printer_factory()
  1452. db_session.add(
  1453. SpoolmanSlotAssignment(
  1454. printer_id=printer.id,
  1455. ams_id=0,
  1456. tray_id=2,
  1457. spoolman_spool_id=99,
  1458. )
  1459. )
  1460. db_session.add(
  1461. SpoolmanKProfile(
  1462. spoolman_spool_id=99,
  1463. printer_id=printer.id,
  1464. extruder=0,
  1465. nozzle_diameter="0.4",
  1466. k_value=0.030,
  1467. cali_idx=77,
  1468. )
  1469. )
  1470. await db_session.commit()
  1471. mock_client = MagicMock()
  1472. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1473. state = _build_h2d_state(cali_idx=5)
  1474. with (
  1475. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1476. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1477. _patch_async_session_to(db_session),
  1478. ):
  1479. mock_pm.get_client.return_value = mock_client
  1480. mock_pm.get_status.return_value = state
  1481. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1482. mock_client.extrusion_cali_sel.assert_called_once()
  1483. kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
  1484. assert kwargs["cali_idx"] == 77 # Spoolman stored profile
  1485. @pytest.mark.asyncio
  1486. @pytest.mark.integration
  1487. async def test_spoolman_no_kp_uses_live(self, db_session, printer_factory):
  1488. """Spoolman SlotAssignment but no SpoolmanKProfile → live cali_idx (Stage 3)."""
  1489. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1490. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1491. printer = await printer_factory()
  1492. db_session.add(
  1493. SpoolmanSlotAssignment(
  1494. printer_id=printer.id,
  1495. ams_id=0,
  1496. tray_id=2,
  1497. spoolman_spool_id=99,
  1498. )
  1499. )
  1500. await db_session.commit()
  1501. mock_client = MagicMock()
  1502. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1503. state = _build_h2d_state(cali_idx=5)
  1504. with (
  1505. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1506. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1507. _patch_async_session_to(db_session),
  1508. ):
  1509. mock_pm.get_client.return_value = mock_client
  1510. mock_pm.get_status.return_value = state
  1511. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1512. mock_client.extrusion_cali_sel.assert_called_once()
  1513. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  1514. @pytest.mark.asyncio
  1515. @pytest.mark.integration
  1516. async def test_no_assignment_uses_live(self, db_session, printer_factory):
  1517. """No assignment of any kind + live cali_idx >= 0 → live fallback."""
  1518. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1519. printer = await printer_factory()
  1520. mock_client = MagicMock()
  1521. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1522. state = _build_h2d_state(cali_idx=5)
  1523. with (
  1524. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1525. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1526. _patch_async_session_to(db_session),
  1527. ):
  1528. mock_pm.get_client.return_value = mock_client
  1529. mock_pm.get_status.return_value = state
  1530. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1531. mock_client.extrusion_cali_sel.assert_called_once()
  1532. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
  1533. @pytest.mark.asyncio
  1534. @pytest.mark.integration
  1535. async def test_negative_live_cali_idx_skipped(self, db_session, printer_factory):
  1536. """No assignment + live cali_idx=-1 → no MQTT call (invalid live value)."""
  1537. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1538. printer = await printer_factory()
  1539. mock_client = MagicMock()
  1540. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1541. state = _build_h2d_state(cali_idx=-1)
  1542. with (
  1543. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1544. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1545. _patch_async_session_to(db_session),
  1546. ):
  1547. mock_pm.get_client.return_value = mock_client
  1548. mock_pm.get_status.return_value = state
  1549. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1550. mock_client.extrusion_cali_sel.assert_not_called()
  1551. @pytest.mark.asyncio
  1552. @pytest.mark.integration
  1553. async def test_no_assignment_no_live_cali_idx_no_call(self, db_session, printer_factory):
  1554. """No assignment of any kind AND no live cali_idx in tray → no MQTT call.
  1555. Distinct from test_negative_live_cali_idx_skipped: that test has
  1556. cali_idx=-1 in raw_data; this one omits the field entirely (returns
  1557. None from .get("cali_idx")). Both must result in no MQTT call.
  1558. """
  1559. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1560. printer = await printer_factory()
  1561. mock_client = MagicMock()
  1562. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1563. # State with NO cali_idx field on tray at all
  1564. nozzle = MagicMock(nozzle_diameter="0.4")
  1565. state = MagicMock()
  1566. state.nozzles = [nozzle]
  1567. state.ams_extruder_map = {"0": 0}
  1568. state.raw_data = {
  1569. "ams": [
  1570. {
  1571. "id": 0,
  1572. "tray": [
  1573. {
  1574. "id": 2,
  1575. "tray_type": "PLA",
  1576. "tag_uid": "AABBCC1122334400",
  1577. "tray_uuid": "11223344556677880011223344556677",
  1578. "tray_info_idx": "GFL05",
  1579. # cali_idx field intentionally omitted
  1580. }
  1581. ],
  1582. }
  1583. ]
  1584. }
  1585. with (
  1586. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1587. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1588. _patch_async_session_to(db_session),
  1589. ):
  1590. mock_pm.get_client.return_value = mock_client
  1591. mock_pm.get_status.return_value = state
  1592. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1593. mock_client.extrusion_cali_sel.assert_not_called()
  1594. @pytest.mark.asyncio
  1595. @pytest.mark.integration
  1596. async def test_extruder_mismatch_uses_kp_as_fallback(self, db_session, printer_factory):
  1597. """K-profile for extruder=1 but slot is extruder=0 → no exact match,
  1598. but the kp is used as extruder-agnostic fallback rather than dropped.
  1599. Hard-skipping on extruder mismatch was the previous behavior; in
  1600. practice it caused stored K-profiles to be silently ignored whenever
  1601. the AMS-extruder mapping had shifted (or when only one of the two
  1602. extruders was ever calibrated for a given spool). The cascade now
  1603. prefers an exact extruder match but falls back to any matching kp
  1604. for the same printer + nozzle.
  1605. """
  1606. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1607. from backend.app.models.spool import Spool
  1608. from backend.app.models.spool_assignment import SpoolAssignment
  1609. from backend.app.models.spool_k_profile import SpoolKProfile
  1610. printer = await printer_factory()
  1611. spool = Spool(material="PLA")
  1612. db_session.add(spool)
  1613. await db_session.flush()
  1614. db_session.add(
  1615. SpoolAssignment(
  1616. spool_id=spool.id,
  1617. printer_id=printer.id,
  1618. ams_id=0,
  1619. tray_id=2,
  1620. )
  1621. )
  1622. # K-profile is for extruder=1, but slot's ams_extruder_map["0"]=0
  1623. db_session.add(
  1624. SpoolKProfile(
  1625. spool_id=spool.id,
  1626. printer_id=printer.id,
  1627. extruder=1,
  1628. nozzle_diameter="0.4",
  1629. k_value=0.025,
  1630. cali_idx=42,
  1631. )
  1632. )
  1633. await db_session.commit()
  1634. mock_client = MagicMock()
  1635. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1636. state = _build_h2d_state(cali_idx=5)
  1637. with (
  1638. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1639. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1640. _patch_async_session_to(db_session),
  1641. ):
  1642. mock_pm.get_client.return_value = mock_client
  1643. mock_pm.get_status.return_value = state
  1644. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1645. # No exact extruder match, but the stored kp wins as the
  1646. # extruder-agnostic fallback over live cali_idx=5.
  1647. mock_client.extrusion_cali_sel.assert_called_once()
  1648. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1649. @pytest.mark.asyncio
  1650. @pytest.mark.integration
  1651. async def test_extruder_exact_match_preferred_over_fallback(
  1652. self,
  1653. db_session,
  1654. printer_factory,
  1655. ):
  1656. """When two kp rows exist, one with matching extruder and one without,
  1657. the exact-extruder kp wins (extruder-agnostic fallback only fires when
  1658. no exact match exists).
  1659. """
  1660. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1661. from backend.app.models.spool import Spool
  1662. from backend.app.models.spool_assignment import SpoolAssignment
  1663. from backend.app.models.spool_k_profile import SpoolKProfile
  1664. printer = await printer_factory()
  1665. spool = Spool(material="PLA")
  1666. db_session.add(spool)
  1667. await db_session.flush()
  1668. db_session.add(
  1669. SpoolAssignment(
  1670. spool_id=spool.id,
  1671. printer_id=printer.id,
  1672. ams_id=0,
  1673. tray_id=2,
  1674. )
  1675. )
  1676. # Two kp rows: extruder=1 (mismatch w/ slot extruder=0) and extruder=0 (exact)
  1677. db_session.add(
  1678. SpoolKProfile(
  1679. spool_id=spool.id,
  1680. printer_id=printer.id,
  1681. extruder=1,
  1682. nozzle_diameter="0.4",
  1683. k_value=0.030,
  1684. cali_idx=99,
  1685. )
  1686. )
  1687. db_session.add(
  1688. SpoolKProfile(
  1689. spool_id=spool.id,
  1690. printer_id=printer.id,
  1691. extruder=0,
  1692. nozzle_diameter="0.4",
  1693. k_value=0.025,
  1694. cali_idx=42,
  1695. )
  1696. )
  1697. await db_session.commit()
  1698. mock_client = MagicMock()
  1699. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1700. state = _build_h2d_state(cali_idx=5)
  1701. with (
  1702. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1703. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1704. _patch_async_session_to(db_session),
  1705. ):
  1706. mock_pm.get_client.return_value = mock_client
  1707. mock_pm.get_status.return_value = state
  1708. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1709. # Exact-extruder=0 kp wins (cali_idx=42), not the extruder=1 fallback (99)
  1710. mock_client.extrusion_cali_sel.assert_called_once()
  1711. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1712. @pytest.mark.asyncio
  1713. @pytest.mark.integration
  1714. async def test_regression_bug_a_kp_extruder_attr(self, db_session, printer_factory):
  1715. """Regression test for Phase 13 P13-2a Bug A.
  1716. Pre-fix Z.2910 used `kp.extruder_id` (AttributeError on SpoolKProfile,
  1717. silently swallowed by outer try/except). On dual-nozzle printers with
  1718. slot_extruder != None this caused the K-profile match loop to crash.
  1719. After P13-2a the field name is correct: `kp.extruder`.
  1720. """
  1721. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1722. from backend.app.models.spool import Spool
  1723. from backend.app.models.spool_assignment import SpoolAssignment
  1724. from backend.app.models.spool_k_profile import SpoolKProfile
  1725. printer = await printer_factory()
  1726. spool = Spool(material="PLA")
  1727. db_session.add(spool)
  1728. await db_session.flush()
  1729. db_session.add(
  1730. SpoolAssignment(
  1731. spool_id=spool.id,
  1732. printer_id=printer.id,
  1733. ams_id=0,
  1734. tray_id=2,
  1735. )
  1736. )
  1737. # extruder=0 matches slot_extruder=0 (from ams_extruder_map={"0":0})
  1738. db_session.add(
  1739. SpoolKProfile(
  1740. spool_id=spool.id,
  1741. printer_id=printer.id,
  1742. extruder=0,
  1743. nozzle_diameter="0.4",
  1744. k_value=0.025,
  1745. cali_idx=42,
  1746. )
  1747. )
  1748. await db_session.commit()
  1749. mock_client = MagicMock()
  1750. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1751. state = _build_h2d_state(cali_idx=5)
  1752. with (
  1753. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1754. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1755. _patch_async_session_to(db_session),
  1756. ):
  1757. mock_pm.get_client.return_value = mock_client
  1758. mock_pm.get_status.return_value = state
  1759. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1760. # If Bug A regressed (kp.extruder_id), the loop would AttributeError → silent fail
  1761. # → no extrusion_cali_sel call. Post-fix the loop matches and sends cali_idx=42.
  1762. mock_client.extrusion_cali_sel.assert_called_once()
  1763. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1764. @pytest.mark.asyncio
  1765. @pytest.mark.integration
  1766. async def test_tag_fallback_finds_spool_when_assignment_missing(
  1767. self,
  1768. db_session,
  1769. printer_factory,
  1770. ):
  1771. """Stage 1b regression for the maintainer's #2 reproducer on H2D:
  1772. reset slot, trigger re-read → slot ends up on the default K-profile
  1773. instead of the spool's stored profile.
  1774. Setup mirrors the bug:
  1775. - Spool has tray_uuid set (the RFID tag was registered earlier).
  1776. - SpoolKProfile exists for that spool with cali_idx=42.
  1777. - NO SpoolAssignment row — the reset deleted it before the re-read
  1778. triggered _apply_pa_after_refresh, and tag-auto-detect has not
  1779. re-created it yet within the 5 s sleep window.
  1780. - Live tray.cali_idx=5 (firmware-default after the RFID re-read).
  1781. Without Stage 1b the cascade falls through to Stage 3 and re-asserts
  1782. the firmware-default cali_idx=5. With Stage 1b it locates the spool by
  1783. the live tray's tray_uuid and applies the stored cali_idx=42.
  1784. """
  1785. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1786. from backend.app.models.spool import Spool
  1787. from backend.app.models.spool_k_profile import SpoolKProfile
  1788. printer = await printer_factory()
  1789. # Spool with tray_uuid matching the one _build_h2d_state puts on the tray
  1790. spool = Spool(
  1791. material="PLA",
  1792. color_name="Red",
  1793. rgba="FF0000FF",
  1794. tray_uuid="11223344556677880011223344556677",
  1795. tag_uid="AABBCC1122334400",
  1796. )
  1797. db_session.add(spool)
  1798. await db_session.flush()
  1799. # K-profile is bound to the spool, not to a slot
  1800. db_session.add(
  1801. SpoolKProfile(
  1802. spool_id=spool.id,
  1803. printer_id=printer.id,
  1804. extruder=0,
  1805. nozzle_diameter="0.4",
  1806. k_value=0.025,
  1807. cali_idx=42,
  1808. )
  1809. )
  1810. # NOTE: deliberately no SpoolAssignment — that's the bug condition.
  1811. await db_session.commit()
  1812. mock_client = MagicMock()
  1813. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1814. state = _build_h2d_state(cali_idx=5)
  1815. with (
  1816. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1817. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1818. _patch_async_session_to(db_session),
  1819. ):
  1820. mock_pm.get_client.return_value = mock_client
  1821. mock_pm.get_status.return_value = state
  1822. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1823. # Stage 1b should match the spool by tray_uuid → stored cali_idx=42 wins
  1824. # over live cali_idx=5. Pre-fix this would have been 5 (firmware default).
  1825. mock_client.extrusion_cali_sel.assert_called_once()
  1826. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
  1827. @pytest.mark.asyncio
  1828. @pytest.mark.integration
  1829. async def test_tag_fallback_matches_by_tag_uid_when_uuid_zero(
  1830. self,
  1831. db_session,
  1832. printer_factory,
  1833. ):
  1834. """Stage 1b: when tray_uuid is the zero sentinel but tag_uid is real,
  1835. match by tag_uid. Older firmwares occasionally report a zero tray_uuid
  1836. right after RFID re-read while the tag_uid is already populated."""
  1837. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1838. from backend.app.models.spool import Spool
  1839. from backend.app.models.spool_k_profile import SpoolKProfile
  1840. printer = await printer_factory()
  1841. # Spool indexed by tag_uid, not tray_uuid
  1842. spool = Spool(material="PLA", tag_uid="AABBCC1122334400")
  1843. db_session.add(spool)
  1844. await db_session.flush()
  1845. db_session.add(
  1846. SpoolKProfile(
  1847. spool_id=spool.id,
  1848. printer_id=printer.id,
  1849. extruder=0,
  1850. nozzle_diameter="0.4",
  1851. k_value=0.025,
  1852. cali_idx=99,
  1853. )
  1854. )
  1855. await db_session.commit()
  1856. mock_client = MagicMock()
  1857. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1858. # Build a state where the tray reports a real tag_uid but a zero tray_uuid
  1859. # while still passing is_bambu_tag (tag_uid + tray_info_idx is sufficient).
  1860. state = _build_h2d_state(cali_idx=5)
  1861. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  1862. with (
  1863. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1864. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1865. _patch_async_session_to(db_session),
  1866. ):
  1867. mock_pm.get_client.return_value = mock_client
  1868. mock_pm.get_status.return_value = state
  1869. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1870. mock_client.extrusion_cali_sel.assert_called_once()
  1871. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 99
  1872. @pytest.mark.asyncio
  1873. @pytest.mark.integration
  1874. async def test_tag_fallback_skipped_when_zero_sentinels(
  1875. self,
  1876. db_session,
  1877. printer_factory,
  1878. ):
  1879. """Stage 1b: when both tray_uuid and tag_uid are zero sentinels, the
  1880. fallback must not match any spool (would otherwise pick up an
  1881. unrelated spool created with empty/zero tag fields). Falls through
  1882. to Stage 3 live cali_idx as before.
  1883. """
  1884. from backend.app.api.routes.printers import _apply_pa_after_refresh
  1885. from backend.app.models.spool import Spool
  1886. from backend.app.models.spool_k_profile import SpoolKProfile
  1887. printer = await printer_factory()
  1888. # Decoy spool with no tag info — must NOT match
  1889. spool = Spool(material="PLA")
  1890. db_session.add(spool)
  1891. await db_session.flush()
  1892. db_session.add(
  1893. SpoolKProfile(
  1894. spool_id=spool.id,
  1895. printer_id=printer.id,
  1896. extruder=0,
  1897. nozzle_diameter="0.4",
  1898. k_value=0.025,
  1899. cali_idx=42,
  1900. )
  1901. )
  1902. await db_session.commit()
  1903. mock_client = MagicMock()
  1904. mock_client.extrusion_cali_sel = MagicMock(return_value=True)
  1905. state = _build_h2d_state(cali_idx=7)
  1906. # Force both tag fields to the zero sentinels but keep tray_info_idx
  1907. # so is_bambu_tag still passes (preset present)
  1908. state.raw_data["ams"][0]["tray"][0]["tag_uid"] = "0000000000000000"
  1909. state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
  1910. with (
  1911. patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
  1912. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  1913. _patch_async_session_to(db_session),
  1914. ):
  1915. mock_pm.get_client.return_value = mock_client
  1916. mock_pm.get_status.return_value = state
  1917. # is_bambu_tag actually rejects both-zero + only-preset, so the
  1918. # function returns early. We just want to confirm we didn't blow
  1919. # up scanning for a tag-fallback spool.
  1920. await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
  1921. # is_bambu_tag short-circuits early when both UID and UUID are zero,
  1922. # so no MQTT call should fire and the decoy spool's cali_idx=42 must
  1923. # NOT leak through.
  1924. if mock_client.extrusion_cali_sel.called:
  1925. assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] != 42
  1926. class TestConfigureAmsSlotPersistsKProfile:
  1927. """Phase 13 P13-T-BE-2: configure_ams_slot persists K-profile to DB.
  1928. Pre-Phase-13 the endpoint sent extrusion_cali_sel via MQTT but never
  1929. recorded the choice in spool_k_profile / spoolman_k_profile, so the next
  1930. RFID re-read had no stored profile to apply.
  1931. """
  1932. @pytest.mark.asyncio
  1933. @pytest.mark.integration
  1934. async def test_writes_spoolman_kprofile_when_spoolman_assigned(
  1935. self,
  1936. async_client: AsyncClient,
  1937. db_session,
  1938. printer_factory,
  1939. ):
  1940. """SpoolmanSlotAssignment present → SpoolmanKProfile row created with cali_idx + k_value + name."""
  1941. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  1942. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  1943. printer = await printer_factory(model="H2D")
  1944. db_session.add(
  1945. SpoolmanSlotAssignment(
  1946. printer_id=printer.id,
  1947. ams_id=0,
  1948. tray_id=3,
  1949. spoolman_spool_id=216,
  1950. )
  1951. )
  1952. await db_session.commit()
  1953. mock_client = MagicMock()
  1954. mock_client.ams_set_filament_setting.return_value = True
  1955. mock_client.extrusion_cali_sel.return_value = True
  1956. mock_client.extrusion_cali_set.return_value = True
  1957. mock_client.request_status_update.return_value = True
  1958. mock_state = MagicMock()
  1959. mock_state.ams_extruder_map = {"0": 0}
  1960. mock_state.raw_data = {"ams": {"ams": []}}
  1961. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  1962. mock_pm.get_client.return_value = mock_client
  1963. mock_pm.get_status.return_value = mock_state
  1964. response = await async_client.post(
  1965. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  1966. params={
  1967. "tray_info_idx": "GFL05",
  1968. "tray_type": "PLA",
  1969. "tray_sub_brands": "Bambu PLA Metal",
  1970. "tray_color": "FF8800FF",
  1971. "nozzle_temp_min": 220,
  1972. "nozzle_temp_max": 240,
  1973. "cali_idx": 5,
  1974. "nozzle_diameter": "0.4",
  1975. "k_value": 0.022,
  1976. },
  1977. )
  1978. assert response.status_code == 200
  1979. kp_result = await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  1980. kp = kp_result.scalar_one_or_none()
  1981. assert kp is not None
  1982. assert kp.cali_idx == 5
  1983. assert kp.k_value == pytest.approx(0.022)
  1984. assert kp.extruder == 0
  1985. assert kp.nozzle_diameter == "0.4"
  1986. assert kp.name == "Bambu PLA Metal"
  1987. @pytest.mark.asyncio
  1988. @pytest.mark.integration
  1989. async def test_writes_spool_kprofile_when_local_assigned(
  1990. self,
  1991. async_client: AsyncClient,
  1992. db_session,
  1993. printer_factory,
  1994. ):
  1995. """Local SpoolAssignment present → SpoolKProfile row created."""
  1996. from backend.app.models.spool import Spool
  1997. from backend.app.models.spool_assignment import SpoolAssignment
  1998. from backend.app.models.spool_k_profile import SpoolKProfile
  1999. printer = await printer_factory(model="H2D")
  2000. spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
  2001. db_session.add(spool)
  2002. await db_session.flush()
  2003. db_session.add(
  2004. SpoolAssignment(
  2005. spool_id=spool.id,
  2006. printer_id=printer.id,
  2007. ams_id=0,
  2008. tray_id=3,
  2009. )
  2010. )
  2011. await db_session.commit()
  2012. spool_id = spool.id
  2013. mock_client = MagicMock()
  2014. mock_client.ams_set_filament_setting.return_value = True
  2015. mock_client.extrusion_cali_sel.return_value = True
  2016. mock_client.extrusion_cali_set.return_value = True
  2017. mock_client.request_status_update.return_value = True
  2018. mock_state = MagicMock()
  2019. mock_state.ams_extruder_map = {"0": 0}
  2020. mock_state.raw_data = {"ams": {"ams": []}}
  2021. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2022. mock_pm.get_client.return_value = mock_client
  2023. mock_pm.get_status.return_value = mock_state
  2024. response = await async_client.post(
  2025. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2026. params={
  2027. "tray_info_idx": "PFUSdev01",
  2028. "tray_type": "PLA",
  2029. "tray_sub_brands": "Devil Design PLA",
  2030. "tray_color": "FF0000FF",
  2031. "nozzle_temp_min": 220,
  2032. "nozzle_temp_max": 240,
  2033. "cali_idx": 7,
  2034. "nozzle_diameter": "0.4",
  2035. "k_value": 0.028,
  2036. },
  2037. )
  2038. assert response.status_code == 200
  2039. kp_result = await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2040. kp = kp_result.scalar_one_or_none()
  2041. assert kp is not None
  2042. assert kp.cali_idx == 7
  2043. assert kp.k_value == pytest.approx(0.028)
  2044. assert kp.extruder == 0
  2045. assert kp.nozzle_diameter == "0.4"
  2046. assert kp.name == "Devil Design PLA"
  2047. @pytest.mark.asyncio
  2048. @pytest.mark.integration
  2049. async def test_no_assignment_no_persist(
  2050. self,
  2051. async_client: AsyncClient,
  2052. db_session,
  2053. printer_factory,
  2054. ):
  2055. """No SpoolAssignment AND no SpoolmanSlotAssignment → no DB write, MQTT still sent."""
  2056. from backend.app.models.spool_k_profile import SpoolKProfile
  2057. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2058. printer = await printer_factory(model="H2D")
  2059. mock_client = MagicMock()
  2060. mock_client.ams_set_filament_setting.return_value = True
  2061. mock_client.extrusion_cali_sel.return_value = True
  2062. mock_client.request_status_update.return_value = True
  2063. mock_state = MagicMock()
  2064. mock_state.ams_extruder_map = {"0": 0}
  2065. mock_state.raw_data = {"ams": {"ams": []}}
  2066. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2067. mock_pm.get_client.return_value = mock_client
  2068. mock_pm.get_status.return_value = mock_state
  2069. response = await async_client.post(
  2070. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2071. params={
  2072. "tray_info_idx": "GFL05",
  2073. "tray_type": "PLA",
  2074. "tray_sub_brands": "PLA Basic",
  2075. "tray_color": "FFFFFFFF",
  2076. "nozzle_temp_min": 190,
  2077. "nozzle_temp_max": 230,
  2078. "cali_idx": 5,
  2079. "k_value": 0.020,
  2080. },
  2081. )
  2082. assert response.status_code == 200
  2083. # MQTT sent (was successful), but no DB writes
  2084. mock_client.extrusion_cali_sel.assert_called_once()
  2085. local_count = (await db_session.execute(select(SpoolKProfile))).scalars().all()
  2086. sm_count = (await db_session.execute(select(SpoolmanKProfile))).scalars().all()
  2087. assert len(local_count) == 0
  2088. assert len(sm_count) == 0
  2089. @pytest.mark.asyncio
  2090. @pytest.mark.integration
  2091. async def test_negative_cali_idx_no_persist(
  2092. self,
  2093. async_client: AsyncClient,
  2094. db_session,
  2095. printer_factory,
  2096. ):
  2097. """cali_idx=-1 (no profile selected) → no DB write even when assignment exists."""
  2098. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2099. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2100. printer = await printer_factory(model="H2D")
  2101. db_session.add(
  2102. SpoolmanSlotAssignment(
  2103. printer_id=printer.id,
  2104. ams_id=0,
  2105. tray_id=3,
  2106. spoolman_spool_id=216,
  2107. )
  2108. )
  2109. await db_session.commit()
  2110. mock_client = MagicMock()
  2111. mock_client.ams_set_filament_setting.return_value = True
  2112. mock_client.extrusion_cali_sel.return_value = True
  2113. mock_client.extrusion_cali_set.return_value = True
  2114. mock_client.request_status_update.return_value = True
  2115. mock_state = MagicMock()
  2116. mock_state.ams_extruder_map = {"0": 0}
  2117. mock_state.raw_data = {"ams": {"ams": []}}
  2118. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2119. mock_pm.get_client.return_value = mock_client
  2120. mock_pm.get_status.return_value = mock_state
  2121. response = await async_client.post(
  2122. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2123. params={
  2124. "tray_info_idx": "GFL05",
  2125. "tray_type": "PLA",
  2126. "tray_sub_brands": "PLA Basic",
  2127. "tray_color": "FFFFFFFF",
  2128. "nozzle_temp_min": 190,
  2129. "nozzle_temp_max": 230,
  2130. "cali_idx": -1,
  2131. "k_value": 0.0,
  2132. },
  2133. )
  2134. assert response.status_code == 200
  2135. sm_kps = (
  2136. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  2137. .scalars()
  2138. .all()
  2139. )
  2140. assert len(sm_kps) == 0 # cali_idx=-1 means "no profile" — don't write
  2141. @pytest.mark.asyncio
  2142. @pytest.mark.integration
  2143. async def test_zero_cali_idx_persists(
  2144. self,
  2145. async_client: AsyncClient,
  2146. db_session,
  2147. printer_factory,
  2148. ):
  2149. """cali_idx=0 is the first valid profile slot (NOT a sentinel for missing)."""
  2150. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2151. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2152. printer = await printer_factory(model="H2D")
  2153. db_session.add(
  2154. SpoolmanSlotAssignment(
  2155. printer_id=printer.id,
  2156. ams_id=0,
  2157. tray_id=3,
  2158. spoolman_spool_id=216,
  2159. )
  2160. )
  2161. await db_session.commit()
  2162. mock_client = MagicMock()
  2163. mock_client.ams_set_filament_setting.return_value = True
  2164. mock_client.extrusion_cali_sel.return_value = True
  2165. mock_client.request_status_update.return_value = True
  2166. mock_state = MagicMock()
  2167. mock_state.ams_extruder_map = {"0": 0}
  2168. mock_state.raw_data = {"ams": {"ams": []}}
  2169. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2170. mock_pm.get_client.return_value = mock_client
  2171. mock_pm.get_status.return_value = mock_state
  2172. response = await async_client.post(
  2173. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2174. params={
  2175. "tray_info_idx": "GFL05",
  2176. "tray_type": "PLA",
  2177. "tray_sub_brands": "PLA Basic",
  2178. "tray_color": "FFFFFFFF",
  2179. "nozzle_temp_min": 190,
  2180. "nozzle_temp_max": 230,
  2181. "cali_idx": 0,
  2182. "k_value": 0.020,
  2183. },
  2184. )
  2185. assert response.status_code == 200
  2186. kp = (
  2187. await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
  2188. ).scalar_one_or_none()
  2189. assert kp is not None
  2190. assert kp.cali_idx == 0 # explicitly testing 0 is valid
  2191. @pytest.mark.asyncio
  2192. @pytest.mark.integration
  2193. async def test_upsert_idempotent(
  2194. self,
  2195. async_client: AsyncClient,
  2196. db_session,
  2197. printer_factory,
  2198. ):
  2199. """Repeated POSTs update the same row (UNIQUE on spool_id+printer+extruder+nozzle_diameter)."""
  2200. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  2201. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2202. printer = await printer_factory(model="H2D")
  2203. db_session.add(
  2204. SpoolmanSlotAssignment(
  2205. printer_id=printer.id,
  2206. ams_id=0,
  2207. tray_id=3,
  2208. spoolman_spool_id=216,
  2209. )
  2210. )
  2211. await db_session.commit()
  2212. mock_client = MagicMock()
  2213. mock_client.ams_set_filament_setting.return_value = True
  2214. mock_client.extrusion_cali_sel.return_value = True
  2215. mock_client.request_status_update.return_value = True
  2216. mock_state = MagicMock()
  2217. mock_state.ams_extruder_map = {"0": 0}
  2218. mock_state.raw_data = {"ams": {"ams": []}}
  2219. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2220. mock_pm.get_client.return_value = mock_client
  2221. mock_pm.get_status.return_value = mock_state
  2222. # First call with cali_idx=5
  2223. await async_client.post(
  2224. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2225. params={
  2226. "tray_info_idx": "GFL05",
  2227. "tray_type": "PLA",
  2228. "tray_sub_brands": "PLA Basic",
  2229. "tray_color": "FFFFFFFF",
  2230. "nozzle_temp_min": 190,
  2231. "nozzle_temp_max": 230,
  2232. "cali_idx": 5,
  2233. "k_value": 0.020,
  2234. },
  2235. )
  2236. # Second call with cali_idx=10 (same slot/spool/extruder/nozzle)
  2237. await async_client.post(
  2238. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2239. params={
  2240. "tray_info_idx": "GFL05",
  2241. "tray_type": "PLA",
  2242. "tray_sub_brands": "PLA Matte",
  2243. "tray_color": "FFFFFFFF",
  2244. "nozzle_temp_min": 190,
  2245. "nozzle_temp_max": 230,
  2246. "cali_idx": 10,
  2247. "k_value": 0.025,
  2248. },
  2249. )
  2250. # Should be exactly ONE row (updated), not two
  2251. kps = (
  2252. (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
  2253. .scalars()
  2254. .all()
  2255. )
  2256. assert len(kps) == 1
  2257. assert kps[0].cali_idx == 10 # updated to most recent
  2258. assert kps[0].name == "PLA Matte"
  2259. @pytest.mark.asyncio
  2260. @pytest.mark.integration
  2261. async def test_external_slot_extruder_inversion(
  2262. self,
  2263. async_client: AsyncClient,
  2264. db_session,
  2265. printer_factory,
  2266. ):
  2267. """ams_id=255 + tray_id=0 → kp.extruder=1 (ext-L); tray_id=1 → extruder=0 (ext-R)."""
  2268. from backend.app.models.spool import Spool
  2269. from backend.app.models.spool_assignment import SpoolAssignment
  2270. from backend.app.models.spool_k_profile import SpoolKProfile
  2271. printer = await printer_factory(model="H2D")
  2272. spool = Spool(material="PLA")
  2273. db_session.add(spool)
  2274. await db_session.flush()
  2275. # Note: SpoolmanSlotAssignment can't store ams_id=255 with tray_id=1
  2276. # under the ck_tray_id_range constraint (0-3 valid). External-slot
  2277. # K-profile persistence is therefore tested via local SpoolAssignment.
  2278. db_session.add(
  2279. SpoolAssignment(
  2280. spool_id=spool.id,
  2281. printer_id=printer.id,
  2282. ams_id=255,
  2283. tray_id=0,
  2284. )
  2285. )
  2286. await db_session.commit()
  2287. spool_id = spool.id
  2288. mock_client = MagicMock()
  2289. mock_client.ams_set_filament_setting.return_value = True
  2290. mock_client.extrusion_cali_sel.return_value = True
  2291. mock_client.request_status_update.return_value = True
  2292. mock_state = MagicMock()
  2293. mock_state.ams_extruder_map = {"0": 0} # truthy so external-inversion path runs
  2294. mock_state.raw_data = {"ams": {"ams": []}}
  2295. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2296. mock_pm.get_client.return_value = mock_client
  2297. mock_pm.get_status.return_value = mock_state
  2298. response = await async_client.post(
  2299. f"/api/v1/printers/{printer.id}/slots/255/0/configure",
  2300. params={
  2301. "tray_info_idx": "GFL05",
  2302. "tray_type": "PLA",
  2303. "tray_sub_brands": "PLA Basic",
  2304. "tray_color": "FFFFFFFF",
  2305. "nozzle_temp_min": 190,
  2306. "nozzle_temp_max": 230,
  2307. "cali_idx": 5,
  2308. "k_value": 0.020,
  2309. },
  2310. )
  2311. assert response.status_code == 200
  2312. kp = (
  2313. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2314. ).scalar_one_or_none()
  2315. assert kp is not None
  2316. # tray_id=0 → extruder = 1 - 0 = 1
  2317. assert kp.extruder == 1
  2318. @pytest.mark.asyncio
  2319. @pytest.mark.integration
  2320. async def test_dual_nozzle_extruder_persists(
  2321. self,
  2322. async_client: AsyncClient,
  2323. db_session,
  2324. printer_factory,
  2325. ):
  2326. """ams_extruder_map with extruder=1 → kp.extruder=1 persisted correctly."""
  2327. from backend.app.models.spool import Spool
  2328. from backend.app.models.spool_assignment import SpoolAssignment
  2329. from backend.app.models.spool_k_profile import SpoolKProfile
  2330. printer = await printer_factory(model="H2D")
  2331. spool = Spool(material="PLA")
  2332. db_session.add(spool)
  2333. await db_session.flush()
  2334. db_session.add(
  2335. SpoolAssignment(
  2336. spool_id=spool.id,
  2337. printer_id=printer.id,
  2338. ams_id=2,
  2339. tray_id=3,
  2340. )
  2341. )
  2342. await db_session.commit()
  2343. spool_id = spool.id
  2344. mock_client = MagicMock()
  2345. mock_client.ams_set_filament_setting.return_value = True
  2346. mock_client.extrusion_cali_sel.return_value = True
  2347. mock_client.request_status_update.return_value = True
  2348. mock_state = MagicMock()
  2349. mock_state.ams_extruder_map = {"2": 1} # AMS 2 is on extruder 1
  2350. mock_state.raw_data = {"ams": {"ams": []}}
  2351. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  2352. mock_pm.get_client.return_value = mock_client
  2353. mock_pm.get_status.return_value = mock_state
  2354. response = await async_client.post(
  2355. f"/api/v1/printers/{printer.id}/slots/2/3/configure",
  2356. params={
  2357. "tray_info_idx": "GFL05",
  2358. "tray_type": "PLA",
  2359. "tray_sub_brands": "PLA Basic",
  2360. "tray_color": "FFFFFFFF",
  2361. "nozzle_temp_min": 190,
  2362. "nozzle_temp_max": 230,
  2363. "cali_idx": 5,
  2364. "k_value": 0.020,
  2365. },
  2366. )
  2367. assert response.status_code == 200
  2368. kp = (
  2369. await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  2370. ).scalar_one_or_none()
  2371. assert kp is not None
  2372. assert kp.extruder == 1
  2373. @pytest.mark.asyncio
  2374. @pytest.mark.integration
  2375. async def test_db_error_does_not_fail_endpoint(
  2376. self,
  2377. async_client: AsyncClient,
  2378. db_session,
  2379. printer_factory,
  2380. ):
  2381. """DB errors during K-profile persistence are best-effort — endpoint still returns 200.
  2382. Verifies the try/except wrap added in P13-3b: if DB upsert fails (e.g.
  2383. because the schema is out of sync, a constraint violation, or any
  2384. other transient error), the MQTT command was already sent successfully
  2385. so we shouldn't return 500 to the user. The error is logged and the
  2386. endpoint returns success.
  2387. """
  2388. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  2389. printer = await printer_factory(model="H2D")
  2390. db_session.add(
  2391. SpoolmanSlotAssignment(
  2392. printer_id=printer.id,
  2393. ams_id=0,
  2394. tray_id=3,
  2395. spoolman_spool_id=216,
  2396. )
  2397. )
  2398. await db_session.commit()
  2399. mock_client = MagicMock()
  2400. mock_client.ams_set_filament_setting.return_value = True
  2401. mock_client.extrusion_cali_sel.return_value = True
  2402. mock_client.request_status_update.return_value = True
  2403. mock_state = MagicMock()
  2404. mock_state.ams_extruder_map = {"0": 0}
  2405. mock_state.raw_data = {"ams": {"ams": []}}
  2406. # Force the K-profile persistence path to fail by patching the
  2407. # SpoolmanKProfile model class with a sentinel that raises when
  2408. # instantiated. The MQTT call has already happened by then, so the
  2409. # endpoint must catch and log without returning 500.
  2410. with (
  2411. patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
  2412. patch(
  2413. "backend.app.models.spoolman_k_profile.SpoolmanKProfile",
  2414. side_effect=RuntimeError("Simulated DB error"),
  2415. ),
  2416. ):
  2417. mock_pm.get_client.return_value = mock_client
  2418. mock_pm.get_status.return_value = mock_state
  2419. response = await async_client.post(
  2420. f"/api/v1/printers/{printer.id}/slots/0/3/configure",
  2421. params={
  2422. "tray_info_idx": "GFL05",
  2423. "tray_type": "PLA",
  2424. "tray_sub_brands": "PLA Basic",
  2425. "tray_color": "FFFFFFFF",
  2426. "nozzle_temp_min": 190,
  2427. "nozzle_temp_max": 230,
  2428. "cali_idx": 5,
  2429. "k_value": 0.020,
  2430. },
  2431. )
  2432. # Endpoint returns success — MQTT was sent, K-profile failed silently
  2433. assert response.status_code == 200
  2434. # MQTT was indeed called
  2435. mock_client.extrusion_cali_sel.assert_called_once()