test_library_slice_api.py 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084
  1. """Integration tests for the slice-via-API flow.
  2. Routes under test:
  3. - POST /library/files/{id}/slice (returns 202 + job_id; bg task does the work)
  4. - POST /archives/{id}/slice (same shape; result lands in archives table)
  5. - GET /slice-jobs/{id} (poll for terminal state)
  6. The synchronous validation paths (404 missing source, 400 wrong file type)
  7. are tested directly. The bg-task paths poll until the job finishes and then
  8. assert on the captured state.
  9. """
  10. from __future__ import annotations
  11. import asyncio
  12. import io
  13. import json
  14. import zipfile
  15. from collections.abc import Callable
  16. import httpx
  17. import pytest
  18. from httpx import AsyncClient
  19. from backend.app.api.routes.library import _slicer_rejection_message
  20. from backend.app.core.config import settings as app_settings
  21. from backend.app.models.library import LibraryFile
  22. from backend.app.models.local_preset import LocalPreset
  23. from backend.app.models.settings import Settings as SettingsModel
  24. from backend.app.services import slicer_api as slicer_api_module
  25. from backend.app.services.slice_dispatch import slice_dispatch
  26. # ---------------------------------------------------------------------------
  27. # Helpers
  28. # ---------------------------------------------------------------------------
  29. def _make_3mf_with_settings(settings_payload: dict | None = None) -> bytes:
  30. """Build a tiny in-memory 3MF zip with all the embedded-config files
  31. that real-world Bambu Studio / OrcaSlicer 3MFs ship with.
  32. The strip-before-forwarding helper has to remove ALL of these (not
  33. just `project_settings.config`) — leftover entries reference printer
  34. / filament IDs from the original slice and trip the CLI's input
  35. validation when a different `--load-settings` triplet is supplied.
  36. """
  37. buf = io.BytesIO()
  38. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  39. zf.writestr("3D/3dmodel.model", "<model/>")
  40. zf.writestr(
  41. "Metadata/project_settings.config",
  42. json.dumps(settings_payload or {"prime_tower_brim_width": "-1"}),
  43. )
  44. zf.writestr("Metadata/model_settings.config", "<config><object id='1'/></config>")
  45. zf.writestr(
  46. "Metadata/slice_info.config",
  47. "<config><plate><metadata key='filament' value='GFL00'/></plate></config>",
  48. )
  49. zf.writestr("Metadata/cut_information.xml", "<cut><part id='1'/></cut>")
  50. return buf.getvalue()
  51. def _install_mock_sidecar(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.AsyncClient:
  52. """Pin a MockTransport-backed httpx client onto the slicer_api singleton
  53. so per-request `SlicerApiService` instances reuse it instead of opening
  54. a real connection."""
  55. client = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=10.0)
  56. slicer_api_module.set_shared_http_client(client)
  57. return client
  58. async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
  59. """Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
  60. The dispatcher runs work as an asyncio task on the same event loop, so
  61. poll-with-sleep here is enough — a few yields and the task finishes.
  62. """
  63. deadline = asyncio.get_event_loop().time() + timeout
  64. while asyncio.get_event_loop().time() < deadline:
  65. r = await client.get(f"/api/v1/slice-jobs/{job_id}")
  66. if r.status_code != 200:
  67. raise AssertionError(f"slice-jobs poll failed: {r.status_code} {r.text}")
  68. body = r.json()
  69. if body["status"] in ("completed", "failed"):
  70. return body
  71. await asyncio.sleep(0.05)
  72. raise AssertionError(f"slice job {job_id} did not finish in {timeout}s")
  73. # ---------------------------------------------------------------------------
  74. # Fixtures
  75. # ---------------------------------------------------------------------------
  76. @pytest.fixture
  77. async def slice_test_setup(db_session, tmp_path):
  78. """Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer."""
  79. storage_dir = tmp_path / "library" / "files"
  80. storage_dir.mkdir(parents=True, exist_ok=True)
  81. src_path = storage_dir / "Cube.stl"
  82. src_path.write_bytes(b"solid Cube\nendsolid\n")
  83. original_base_dir = app_settings.base_dir
  84. app_settings.base_dir = tmp_path
  85. src_file = LibraryFile(
  86. filename="Cube.stl",
  87. file_path=str(src_path.relative_to(tmp_path)),
  88. file_type="stl",
  89. file_size=src_path.stat().st_size,
  90. )
  91. db_session.add(src_file)
  92. presets = {}
  93. for kind in ("printer", "process", "filament"):
  94. p = LocalPreset(
  95. name=f"Test {kind}",
  96. preset_type=kind,
  97. source="orcaslicer",
  98. setting=json.dumps({"name": f"Test {kind}", "type": kind}),
  99. )
  100. db_session.add(p)
  101. presets[kind] = p
  102. db_session.add(SettingsModel(key="preferred_slicer", value="orcaslicer"))
  103. await db_session.commit()
  104. for p in presets.values():
  105. await db_session.refresh(p)
  106. await db_session.refresh(src_file)
  107. yield {
  108. "src_file_id": src_file.id,
  109. "printer_id": presets["printer"].id,
  110. "process_id": presets["process"].id,
  111. "filament_id": presets["filament"].id,
  112. "tmp_path": tmp_path,
  113. }
  114. app_settings.base_dir = original_base_dir
  115. slicer_api_module.set_shared_http_client(None)
  116. # ---------------------------------------------------------------------------
  117. # POST /library/files/{id}/slice — synchronous validation paths
  118. # ---------------------------------------------------------------------------
  119. class TestSliceValidation:
  120. @pytest.mark.asyncio
  121. @pytest.mark.integration
  122. async def test_returns_404_when_source_missing(self, async_client: AsyncClient, slice_test_setup):
  123. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  124. response = await async_client.post(
  125. "/api/v1/library/files/999999/slice",
  126. json={
  127. "printer_preset_id": slice_test_setup["printer_id"],
  128. "process_preset_id": slice_test_setup["process_id"],
  129. "filament_preset_id": slice_test_setup["filament_id"],
  130. },
  131. )
  132. assert response.status_code == 404
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_returns_400_for_wrong_file_type(self, async_client: AsyncClient, db_session, slice_test_setup):
  136. gcode_path = slice_test_setup["tmp_path"] / "library" / "files" / "out.gcode"
  137. gcode_path.write_bytes(b"; gcode\n")
  138. gfile = LibraryFile(
  139. filename="out.gcode",
  140. file_path=str(gcode_path.relative_to(slice_test_setup["tmp_path"])),
  141. file_type="gcode",
  142. file_size=10,
  143. )
  144. db_session.add(gfile)
  145. await db_session.commit()
  146. await db_session.refresh(gfile)
  147. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  148. response = await async_client.post(
  149. f"/api/v1/library/files/{gfile.id}/slice",
  150. json={
  151. "printer_preset_id": slice_test_setup["printer_id"],
  152. "process_preset_id": slice_test_setup["process_id"],
  153. "filament_preset_id": slice_test_setup["filament_id"],
  154. },
  155. )
  156. assert response.status_code == 400
  157. assert "STL, 3MF, or STEP" in response.json()["detail"]
  158. # ---------------------------------------------------------------------------
  159. # POST /library/files/{id}/slice — async dispatch + bg job
  160. # ---------------------------------------------------------------------------
  161. class TestSliceLibraryFile:
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_happy_path_returns_202_then_job_completes_with_library_file(
  165. self, async_client: AsyncClient, slice_test_setup
  166. ):
  167. captured: dict = {}
  168. def handler(request: httpx.Request) -> httpx.Response:
  169. captured["url"] = str(request.url)
  170. return httpx.Response(
  171. status_code=200,
  172. content=b"PK\x03\x04 fake-3mf",
  173. headers={
  174. "x-print-time-seconds": "656",
  175. "x-filament-used-g": "0.94",
  176. "x-filament-used-mm": "302.5",
  177. },
  178. )
  179. _install_mock_sidecar(handler)
  180. response = await async_client.post(
  181. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  182. json={
  183. "printer_preset_id": slice_test_setup["printer_id"],
  184. "process_preset_id": slice_test_setup["process_id"],
  185. "filament_preset_id": slice_test_setup["filament_id"],
  186. },
  187. )
  188. assert response.status_code == 202, response.text
  189. body = response.json()
  190. assert body["status"] == "pending"
  191. assert body["status_url"].startswith("/api/v1/slice-jobs/")
  192. final = await _wait_for_job(async_client, body["job_id"])
  193. assert final["status"] == "completed", final
  194. assert final["result"]["library_file_id"] != slice_test_setup["src_file_id"]
  195. assert final["result"]["print_time_seconds"] == 656
  196. assert captured["url"].endswith("/slice")
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_bed_type_override_patches_process_profile(self, async_client: AsyncClient, slice_test_setup):
  200. """#1337: when SliceRequest.bed_type is set, the process JSON sent to
  201. the sidecar must carry curr_bed_type with that exact value. Without
  202. the patch, slicing high-temp filaments on a "Cool Plate" process
  203. preset fails inside the slicer CLI with "does not support filament 1"
  204. and the user has no way to switch plates from the SliceModal."""
  205. captured: dict = {}
  206. def handler(request: httpx.Request) -> httpx.Response:
  207. captured["body"] = bytes(request.content)
  208. return httpx.Response(
  209. status_code=200,
  210. content=b"PK\x03\x04 fake",
  211. headers={
  212. "x-print-time-seconds": "10",
  213. "x-filament-used-g": "0.1",
  214. "x-filament-used-mm": "1.0",
  215. },
  216. )
  217. _install_mock_sidecar(handler)
  218. response = await async_client.post(
  219. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  220. json={
  221. "printer_preset_id": slice_test_setup["printer_id"],
  222. "process_preset_id": slice_test_setup["process_id"],
  223. "filament_preset_id": slice_test_setup["filament_id"],
  224. "bed_type": "Textured PEI Plate",
  225. },
  226. )
  227. assert response.status_code == 202
  228. final = await _wait_for_job(async_client, response.json()["job_id"])
  229. assert final["status"] == "completed", final
  230. # The presetProfile part of the multipart upload now carries the
  231. # override. Searching the raw body avoids parsing the multipart by
  232. # hand — the substring is unique enough since we control the JSON
  233. # being patched.
  234. assert b'"curr_bed_type": "Textured PEI Plate"' in captured["body"], (
  235. "bed_type override must appear in the process JSON sent to the sidecar"
  236. )
  237. @pytest.mark.asyncio
  238. @pytest.mark.integration
  239. async def test_bed_type_omitted_leaves_process_profile_untouched(self, async_client: AsyncClient, slice_test_setup):
  240. """Companion to the override test: the patch must NOT fire when the
  241. client omits bed_type, so the process preset's own curr_bed_type
  242. (or absence thereof) is forwarded to the sidecar unchanged."""
  243. captured: dict = {}
  244. def handler(request: httpx.Request) -> httpx.Response:
  245. captured["body"] = bytes(request.content)
  246. return httpx.Response(
  247. status_code=200,
  248. content=b"PK\x03\x04 fake",
  249. headers={
  250. "x-print-time-seconds": "10",
  251. "x-filament-used-g": "0.1",
  252. "x-filament-used-mm": "1.0",
  253. },
  254. )
  255. _install_mock_sidecar(handler)
  256. response = await async_client.post(
  257. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  258. json={
  259. "printer_preset_id": slice_test_setup["printer_id"],
  260. "process_preset_id": slice_test_setup["process_id"],
  261. "filament_preset_id": slice_test_setup["filament_id"],
  262. },
  263. )
  264. assert response.status_code == 202
  265. final = await _wait_for_job(async_client, response.json()["job_id"])
  266. assert final["status"] == "completed", final
  267. assert b"curr_bed_type" not in captured["body"], (
  268. "bed_type must stay out of the process JSON when no override is set"
  269. )
  270. @pytest.mark.asyncio
  271. @pytest.mark.integration
  272. async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
  273. self, async_client: AsyncClient, slice_test_setup
  274. ):
  275. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  276. response = await async_client.post(
  277. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  278. json={
  279. # Swap printer/filament — both exist but wrong preset_type.
  280. "printer_preset_id": slice_test_setup["filament_id"],
  281. "process_preset_id": slice_test_setup["process_id"],
  282. "filament_preset_id": slice_test_setup["printer_id"],
  283. },
  284. )
  285. assert response.status_code == 202
  286. final = await _wait_for_job(async_client, response.json()["job_id"])
  287. assert final["status"] == "failed"
  288. assert final["error_status"] == 400
  289. assert "preset_type" in (final["error_detail"] or "")
  290. @pytest.mark.asyncio
  291. @pytest.mark.integration
  292. async def test_unknown_preferred_slicer_fails_with_400(
  293. self, async_client: AsyncClient, db_session, slice_test_setup
  294. ):
  295. await db_session.execute(
  296. SettingsModel.__table__.update().where(SettingsModel.key == "preferred_slicer").values(value="prusaslicer")
  297. )
  298. await db_session.commit()
  299. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  300. response = await async_client.post(
  301. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  302. json={
  303. "printer_preset_id": slice_test_setup["printer_id"],
  304. "process_preset_id": slice_test_setup["process_id"],
  305. "filament_preset_id": slice_test_setup["filament_id"],
  306. },
  307. )
  308. assert response.status_code == 202
  309. final = await _wait_for_job(async_client, response.json()["job_id"])
  310. assert final["status"] == "failed"
  311. assert final["error_status"] == 400
  312. assert "preferred_slicer" in (final["error_detail"] or "")
  313. @pytest.mark.asyncio
  314. @pytest.mark.integration
  315. async def test_sidecar_unreachable_fails_with_502(self, async_client: AsyncClient, slice_test_setup):
  316. def handler(_: httpx.Request) -> httpx.Response:
  317. raise httpx.ConnectError("connection refused")
  318. _install_mock_sidecar(handler)
  319. response = await async_client.post(
  320. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  321. json={
  322. "printer_preset_id": slice_test_setup["printer_id"],
  323. "process_preset_id": slice_test_setup["process_id"],
  324. "filament_preset_id": slice_test_setup["filament_id"],
  325. },
  326. )
  327. assert response.status_code == 202
  328. final = await _wait_for_job(async_client, response.json()["job_id"])
  329. assert final["status"] == "failed"
  330. assert final["error_status"] == 502
  331. assert "unreachable" in (final["error_detail"] or "").lower()
  332. @pytest.mark.asyncio
  333. @pytest.mark.integration
  334. async def test_3mf_falls_back_to_embedded_settings_on_cli_failure(
  335. self, async_client: AsyncClient, db_session, slice_test_setup
  336. ):
  337. # When the slicer CLI fails on the --load-settings path (segfault
  338. # on complex H2D models), Bambuddy retries with no profile triplet
  339. # so the CLI uses the file's embedded settings.
  340. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex.3mf"
  341. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  342. threemf = LibraryFile(
  343. filename="complex.3mf",
  344. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  345. file_type="3mf",
  346. file_size=src_3mf_path.stat().st_size,
  347. )
  348. db_session.add(threemf)
  349. await db_session.commit()
  350. await db_session.refresh(threemf)
  351. call_count = {"n": 0}
  352. def handler(request: httpx.Request) -> httpx.Response:
  353. call_count["n"] += 1
  354. # First call: profile triplet present → simulate CLI 5xx
  355. if call_count["n"] == 1:
  356. return httpx.Response(
  357. status_code=500,
  358. json={"message": "Failed to slice the model"},
  359. )
  360. # Retry: no profile triplet → succeed with embedded settings
  361. return httpx.Response(
  362. status_code=200,
  363. content=b"PK\x03\x04 fake-3mf",
  364. headers={
  365. "x-print-time-seconds": "100",
  366. "x-filament-used-g": "1.0",
  367. "x-filament-used-mm": "100",
  368. },
  369. )
  370. _install_mock_sidecar(handler)
  371. response = await async_client.post(
  372. f"/api/v1/library/files/{threemf.id}/slice",
  373. json={
  374. "printer_preset_id": slice_test_setup["printer_id"],
  375. "process_preset_id": slice_test_setup["process_id"],
  376. "filament_preset_id": slice_test_setup["filament_id"],
  377. },
  378. )
  379. assert response.status_code == 202
  380. final = await _wait_for_job(async_client, response.json()["job_id"])
  381. assert final["status"] == "completed", final
  382. assert final["result"]["used_embedded_settings"] is True
  383. assert call_count["n"] == 2 # primary + fallback retry
  384. @pytest.mark.asyncio
  385. @pytest.mark.integration
  386. async def test_stl_does_not_fall_back_on_cli_failure(self, async_client: AsyncClient, slice_test_setup):
  387. # STL has no embedded settings — the CLI 5xx is terminal.
  388. call_count = {"n": 0}
  389. def handler(_: httpx.Request) -> httpx.Response:
  390. call_count["n"] += 1
  391. return httpx.Response(
  392. status_code=500,
  393. json={"message": "Failed to slice the model"},
  394. )
  395. _install_mock_sidecar(handler)
  396. response = await async_client.post(
  397. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  398. json={
  399. "printer_preset_id": slice_test_setup["printer_id"],
  400. "process_preset_id": slice_test_setup["process_id"],
  401. "filament_preset_id": slice_test_setup["filament_id"],
  402. },
  403. )
  404. assert response.status_code == 202
  405. final = await _wait_for_job(async_client, response.json()["job_id"])
  406. assert final["status"] == "failed"
  407. assert final["error_status"] == 502
  408. assert call_count["n"] == 1 # No retry for STL
  409. @pytest.mark.asyncio
  410. @pytest.mark.integration
  411. async def test_3mf_input_forwarded_unmodified_to_sidecar(
  412. self, async_client: AsyncClient, db_session, slice_test_setup
  413. ):
  414. # 3MF input must be forwarded to the sidecar verbatim — every
  415. # Metadata/*.config the source carries (project_settings,
  416. # model_settings, slice_info, cut_information) is needed by the
  417. # CLI to find plate definitions and baseline config; an earlier
  418. # version of this code stripped them and caused the CLI to
  419. # silently exit immediately after "Initializing StaticPrintConfigs"
  420. # for every 3MF slice. --load-settings overrides the specific
  421. # fields the user changed; the rest comes from the embedded data.
  422. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "real.3mf"
  423. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  424. threemf = LibraryFile(
  425. filename="real.3mf",
  426. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  427. file_type="3mf",
  428. file_size=src_3mf_path.stat().st_size,
  429. )
  430. db_session.add(threemf)
  431. await db_session.commit()
  432. await db_session.refresh(threemf)
  433. captured: dict = {}
  434. def handler(request: httpx.Request) -> httpx.Response:
  435. captured["body"] = request.content
  436. return httpx.Response(
  437. status_code=200,
  438. content=b"PK\x03\x04 fake-3mf",
  439. headers={
  440. "x-print-time-seconds": "1",
  441. "x-filament-used-g": "0",
  442. "x-filament-used-mm": "0",
  443. },
  444. )
  445. _install_mock_sidecar(handler)
  446. response = await async_client.post(
  447. f"/api/v1/library/files/{threemf.id}/slice",
  448. json={
  449. "printer_preset_id": slice_test_setup["printer_id"],
  450. "process_preset_id": slice_test_setup["process_id"],
  451. "filament_preset_id": slice_test_setup["filament_id"],
  452. },
  453. )
  454. assert response.status_code == 202
  455. final = await _wait_for_job(async_client, response.json()["job_id"])
  456. assert final["status"] == "completed", final
  457. # Recover the embedded zip from the multipart body and assert ALL
  458. # the source's Metadata/*.config files are still present — the
  459. # opposite of the previous (broken) "strip everything" test.
  460. body = captured["body"]
  461. pk = body.find(b"PK\x03\x04")
  462. assert pk >= 0, "3MF body not found in multipart payload"
  463. with zipfile.ZipFile(io.BytesIO(body[pk:]), "r") as zin:
  464. names = set(zin.namelist())
  465. assert "Metadata/project_settings.config" in names
  466. assert "Metadata/model_settings.config" in names
  467. assert "Metadata/slice_info.config" in names
  468. assert "Metadata/cut_information.xml" in names
  469. assert "3D/3dmodel.model" in names
  470. class TestSliceWithBundle:
  471. """Bundle dispatch path: when SliceRequest.bundle is set, the dispatch
  472. forwards bundle id + per-category preset names to the sidecar instead
  473. of resolving cloud/local/standard PresetRefs. Same fallback semantics
  474. apply for 3MF inputs whose CLI run fails."""
  475. @pytest.mark.asyncio
  476. @pytest.mark.integration
  477. async def test_bundle_dispatch_forwards_form_fields(self, async_client: AsyncClient, slice_test_setup):
  478. captured: dict = {}
  479. def handler(request: httpx.Request) -> httpx.Response:
  480. captured["body"] = request.content
  481. return httpx.Response(
  482. status_code=200,
  483. content=b"PK\x03\x04 fake-3mf",
  484. headers={
  485. "x-print-time-seconds": "200",
  486. "x-filament-used-g": "1.5",
  487. "x-filament-used-mm": "150",
  488. },
  489. )
  490. _install_mock_sidecar(handler)
  491. response = await async_client.post(
  492. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  493. json={
  494. "bundle": {
  495. "bundle_id": "abc123def456abcd",
  496. "printer_name": "# Bambu Lab H2D 0.4 nozzle",
  497. "process_name": "# 0.20mm Standard @BBL H2D",
  498. "filament_names": [
  499. "# Bambu PLA Basic @BBL H2D",
  500. "# Bambu PETG HF @BBL H2D 0.4 nozzle",
  501. ],
  502. },
  503. },
  504. )
  505. assert response.status_code == 202, response.text
  506. final = await _wait_for_job(async_client, response.json()["job_id"])
  507. assert final["status"] == "completed", final
  508. # Multipart form body should carry the bundle selectors instead of
  509. # the JSON profile attachments. Quick string-level check is enough
  510. # to confirm the dispatch picked the bundle branch.
  511. body = captured["body"]
  512. assert b'name="bundle"' in body
  513. assert b"abc123def456abcd" in body
  514. assert b'name="printerName"' in body
  515. assert b'name="processName"' in body
  516. assert b'name="filamentNames"' in body
  517. # Multi-color filament list joined with ';' on the wire.
  518. assert b"# Bambu PLA Basic @BBL H2D;# Bambu PETG HF @BBL H2D 0.4 nozzle" in body
  519. # Profile attachments must NOT be present — bundle dispatch skips
  520. # PresetRef resolution entirely.
  521. assert b'name="printerProfile"' not in body
  522. assert b'name="presetProfile"' not in body
  523. assert b'name="filamentProfile"' not in body
  524. @pytest.mark.asyncio
  525. @pytest.mark.integration
  526. async def test_bundle_dispatch_forwards_bed_type_when_set(self, async_client: AsyncClient, slice_test_setup):
  527. """#1337 follow-up: bed-type override flows through the bundle path
  528. as a `bedType` form field so the sidecar can pass
  529. `--curr_bed_type` to the CLI. Bambuddy can't patch the bundle's
  530. process JSON locally — the sidecar materialises it from the stored
  531. .bbscfg — so the form field is the only handle."""
  532. captured: dict = {}
  533. def handler(request: httpx.Request) -> httpx.Response:
  534. captured["body"] = bytes(request.content)
  535. return httpx.Response(
  536. status_code=200,
  537. content=b"PK\x03\x04 fake",
  538. headers={
  539. "x-print-time-seconds": "10",
  540. "x-filament-used-g": "0.1",
  541. "x-filament-used-mm": "1.0",
  542. },
  543. )
  544. _install_mock_sidecar(handler)
  545. response = await async_client.post(
  546. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  547. json={
  548. "bundle": {
  549. "bundle_id": "abc",
  550. "printer_name": "# X1C",
  551. "process_name": "# 0.20mm",
  552. "filament_names": ["# Bambu PLA"],
  553. },
  554. "bed_type": "Engineering Plate",
  555. },
  556. )
  557. assert response.status_code == 202
  558. final = await _wait_for_job(async_client, response.json()["job_id"])
  559. assert final["status"] == "completed", final
  560. body = captured["body"]
  561. assert b'name="bedType"' in body
  562. assert b"Engineering Plate" in body
  563. @pytest.mark.asyncio
  564. @pytest.mark.integration
  565. async def test_bundle_dispatch_omits_bed_type_when_unset(self, async_client: AsyncClient, slice_test_setup):
  566. """Companion test: no bed_type ⇒ no bedType form field, so the
  567. bundle's own curr_bed_type is preserved end-to-end."""
  568. captured: dict = {}
  569. def handler(request: httpx.Request) -> httpx.Response:
  570. captured["body"] = bytes(request.content)
  571. return httpx.Response(
  572. status_code=200,
  573. content=b"PK\x03\x04 fake",
  574. headers={
  575. "x-print-time-seconds": "10",
  576. "x-filament-used-g": "0.1",
  577. "x-filament-used-mm": "1.0",
  578. },
  579. )
  580. _install_mock_sidecar(handler)
  581. response = await async_client.post(
  582. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  583. json={
  584. "bundle": {
  585. "bundle_id": "abc",
  586. "printer_name": "# X1C",
  587. "process_name": "# 0.20mm",
  588. "filament_names": ["# Bambu PLA"],
  589. },
  590. },
  591. )
  592. assert response.status_code == 202
  593. final = await _wait_for_job(async_client, response.json()["job_id"])
  594. assert final["status"] == "completed", final
  595. assert b'name="bedType"' not in captured["body"]
  596. @pytest.mark.asyncio
  597. @pytest.mark.integration
  598. async def test_bundle_dispatch_3mf_falls_back_to_embedded_on_5xx(
  599. self, async_client: AsyncClient, db_session, slice_test_setup
  600. ):
  601. # Same fallback as the preset-based path: if the resolved bundle
  602. # triplet crashes the CLI on a 3MF, retry with embedded settings
  603. # so the user gets *something* rather than a hard failure.
  604. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex_bundle.3mf"
  605. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  606. threemf = LibraryFile(
  607. filename="complex_bundle.3mf",
  608. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  609. file_type="3mf",
  610. file_size=src_3mf_path.stat().st_size,
  611. )
  612. db_session.add(threemf)
  613. await db_session.commit()
  614. await db_session.refresh(threemf)
  615. call_count = {"n": 0}
  616. def handler(request: httpx.Request) -> httpx.Response:
  617. call_count["n"] += 1
  618. # First call: bundle path → simulate CLI 5xx
  619. if call_count["n"] == 1:
  620. return httpx.Response(
  621. status_code=500,
  622. json={"message": "Failed to slice the model"},
  623. )
  624. # Retry: no profiles / no bundle → succeed with embedded settings
  625. return httpx.Response(
  626. status_code=200,
  627. content=b"PK\x03\x04 fake-3mf",
  628. headers={
  629. "x-print-time-seconds": "100",
  630. "x-filament-used-g": "1.0",
  631. "x-filament-used-mm": "100",
  632. },
  633. )
  634. _install_mock_sidecar(handler)
  635. response = await async_client.post(
  636. f"/api/v1/library/files/{threemf.id}/slice",
  637. json={
  638. "bundle": {
  639. "bundle_id": "abc",
  640. "printer_name": "P",
  641. "process_name": "Q",
  642. "filament_names": ["F"],
  643. },
  644. },
  645. )
  646. assert response.status_code == 202
  647. final = await _wait_for_job(async_client, response.json()["job_id"])
  648. assert final["status"] == "completed", final
  649. assert final["result"]["used_embedded_settings"] is True
  650. assert call_count["n"] == 2 # bundle attempt + embedded fallback
  651. @pytest.mark.asyncio
  652. @pytest.mark.integration
  653. async def test_bundle_dispatch_404_surfaces_as_400(self, async_client: AsyncClient, slice_test_setup):
  654. # Sidecar returns 404 when the bundle / preset name isn't found —
  655. # the slicer client classifies this as user-correctable input
  656. # error so the dispatch returns 400 to the caller, not 502.
  657. def handler(_: httpx.Request) -> httpx.Response:
  658. return httpx.Response(
  659. status_code=404,
  660. json={"message": 'process preset "Imaginary" not found in bundle "abc"'},
  661. )
  662. _install_mock_sidecar(handler)
  663. response = await async_client.post(
  664. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  665. json={
  666. "bundle": {
  667. "bundle_id": "abc",
  668. "printer_name": "P",
  669. "process_name": "Imaginary",
  670. "filament_names": ["F"],
  671. },
  672. },
  673. )
  674. assert response.status_code == 202
  675. final = await _wait_for_job(async_client, response.json()["job_id"])
  676. assert final["status"] == "failed"
  677. assert final["error_status"] == 400
  678. assert "imaginary" in (final["error_detail"] or "").lower()
  679. # ---------------------------------------------------------------------------
  680. # GET /slice-jobs/{id}
  681. # ---------------------------------------------------------------------------
  682. class TestSliceJobs:
  683. @pytest.mark.asyncio
  684. @pytest.mark.integration
  685. async def test_unknown_job_returns_404(self, async_client: AsyncClient):
  686. # Sweep dispatcher state so a fresh ID is unknown.
  687. slice_dispatch._jobs.clear()
  688. r = await async_client.get("/api/v1/slice-jobs/999999")
  689. assert r.status_code == 404
  690. # ---------------------------------------------------------------------------
  691. # POST /archives/{id}/slice — re-sliced archive reflects the target printer
  692. # ---------------------------------------------------------------------------
  693. def _make_sliced_3mf(printer_model_id: str) -> bytes:
  694. """A minimal sliced-output 3MF that embeds a printer_model_id in
  695. slice_info.config, the way a real Bambu Studio / OrcaSlicer export does.
  696. ThreeMFParser reads this into metadata['sliced_for_model']."""
  697. buf = io.BytesIO()
  698. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  699. zf.writestr("3D/3dmodel.model", "<model/>")
  700. zf.writestr(
  701. "Metadata/slice_info.config",
  702. f"<config><plate><metadata key='printer_model_id' value='{printer_model_id}'/></plate></config>",
  703. )
  704. return buf.getvalue()
  705. class TestSliceArchiveResliceModel:
  706. """Re-slicing an archive for a different printer must stamp the new
  707. archive with the printer it was sliced FOR, not the source's printer."""
  708. @pytest.mark.asyncio
  709. @pytest.mark.integration
  710. async def test_reslice_uses_target_model_not_source_model(
  711. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  712. ):
  713. from backend.app.models.archive import PrintArchive
  714. tmp_path = slice_test_setup["tmp_path"]
  715. # archive_dir is a static path off the real data dir; point it under
  716. # base_dir (= tmp_path) so the new archive's file resolves there.
  717. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  718. # Source archive: a 3MF that was sliced for an X1C.
  719. src_dir = tmp_path / "archives" / "src"
  720. src_dir.mkdir(parents=True, exist_ok=True)
  721. src_3mf = src_dir / "cube.3mf"
  722. src_3mf.write_bytes(_make_3mf_with_settings())
  723. printer = await printer_factory()
  724. source = await archive_factory(
  725. printer.id,
  726. filename="cube.3mf",
  727. file_path=str(src_3mf.relative_to(tmp_path)),
  728. sliced_for_model="X1C",
  729. with_run=False,
  730. )
  731. source_id = source.id
  732. # The slicer returns a 3MF whose embedded printer_model_id is O1D (H2D).
  733. def handler(request: httpx.Request) -> httpx.Response:
  734. return httpx.Response(
  735. status_code=200,
  736. content=_make_sliced_3mf("O1D"),
  737. headers={
  738. "x-print-time-seconds": "600",
  739. "x-filament-used-g": "5.0",
  740. "x-filament-used-mm": "1600.0",
  741. },
  742. )
  743. _install_mock_sidecar(handler)
  744. resp = await async_client.post(
  745. f"/api/v1/archives/{source_id}/slice",
  746. json={
  747. "printer_preset_id": slice_test_setup["printer_id"],
  748. "process_preset_id": slice_test_setup["process_id"],
  749. "filament_preset_id": slice_test_setup["filament_id"],
  750. },
  751. )
  752. assert resp.status_code == 202, resp.text
  753. final = await _wait_for_job(async_client, resp.json()["job_id"])
  754. assert final["status"] == "completed", final
  755. new_id = final["result"]["archive_id"]
  756. assert new_id != source_id
  757. new_archive = await db_session.get(PrintArchive, new_id)
  758. # The fix: the re-sliced archive reflects H2D — the printer it was
  759. # sliced for — instead of inheriting X1C from the source archive.
  760. assert new_archive.sliced_for_model == "H2D"
  761. # Source archive is untouched.
  762. source_reloaded = await db_session.get(PrintArchive, source_id)
  763. assert source_reloaded.sliced_for_model == "X1C"
  764. # ---------------------------------------------------------------------------
  765. # Slicer content rejections surface instead of silently falling back
  766. # ---------------------------------------------------------------------------
  767. class TestSlicerRejectionMessage:
  768. """_slicer_rejection_message distinguishes a real slicer content rejection
  769. (surface it to the user) from a CLI crash (fall back to embedded)."""
  770. def test_extracts_bed_boundary_reason(self):
  771. text = (
  772. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  773. "Some objects are located over the boundary of the heated bed.: "
  774. "Slicer process failed (exit code 204)\nstdout: trace ..."
  775. )
  776. assert _slicer_rejection_message(text) == "Some objects are located over the boundary of the heated bed."
  777. def test_extracts_filament_temp_reason(self):
  778. text = (
  779. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  780. "The temperature difference of the filaments used is too large.: "
  781. "Slicer process failed (exit code 194)"
  782. )
  783. assert _slicer_rejection_message(text) == "The temperature difference of the filaments used is too large."
  784. def test_generic_cli_failure_is_not_a_rejection(self):
  785. # The #1201 CLI-crash signature carries no slicer error_string, so it
  786. # must still fall through to the embedded-settings fallback.
  787. assert _slicer_rejection_message("Slicer CLI failed (500): Failed to slice the model") is None
  788. def test_empty_or_unrelated_text(self):
  789. assert _slicer_rejection_message("") is None
  790. assert _slicer_rejection_message("Slicer sidecar unreachable: connection reset") is None
  791. class TestSliceSlicerRejection:
  792. @pytest.mark.asyncio
  793. @pytest.mark.integration
  794. async def test_3mf_surfaces_slicer_rejection_instead_of_falling_back(
  795. self, async_client: AsyncClient, db_session, slice_test_setup
  796. ):
  797. """A real slicer content rejection (e.g. re-slicing for a printer with
  798. a smaller bed) must surface as a 400 — not silently fall back to the
  799. source 3MF's embedded settings, which would re-slice for the original
  800. printer and hide the problem."""
  801. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "toobig.3mf"
  802. src_3mf_path.write_bytes(_make_3mf_with_settings())
  803. threemf = LibraryFile(
  804. filename="toobig.3mf",
  805. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  806. file_type="3mf",
  807. file_size=src_3mf_path.stat().st_size,
  808. )
  809. db_session.add(threemf)
  810. await db_session.commit()
  811. await db_session.refresh(threemf)
  812. call_count = {"n": 0}
  813. def handler(request: httpx.Request) -> httpx.Response:
  814. call_count["n"] += 1
  815. return httpx.Response(
  816. status_code=500,
  817. json={
  818. "message": (
  819. "Slicing failed with error from slicer: Some objects are "
  820. "located over the boundary of the heated bed."
  821. ),
  822. "details": "Slicer process failed (exit code 204)",
  823. },
  824. )
  825. _install_mock_sidecar(handler)
  826. response = await async_client.post(
  827. f"/api/v1/library/files/{threemf.id}/slice",
  828. json={
  829. "printer_preset_id": slice_test_setup["printer_id"],
  830. "process_preset_id": slice_test_setup["process_id"],
  831. "filament_preset_id": slice_test_setup["filament_id"],
  832. },
  833. )
  834. assert response.status_code == 202
  835. final = await _wait_for_job(async_client, response.json()["job_id"])
  836. assert final["status"] == "failed", final
  837. assert final["error_status"] == 400
  838. assert "boundary of the heated bed" in (final["error_detail"] or "")
  839. # The slicer rejection must NOT trigger the embedded-settings retry.
  840. assert call_count["n"] == 1
  841. # ---------------------------------------------------------------------------
  842. # Nozzle-class re-slice guard — single-nozzle <-> dual-nozzle (H2D) is blocked
  843. # ---------------------------------------------------------------------------
  844. from fastapi import HTTPException # noqa: E402
  845. from backend.app.api.routes.library import ( # noqa: E402
  846. _canonical_printer_model,
  847. guard_nozzle_class_reslice,
  848. )
  849. class TestCanonicalPrinterModel:
  850. """_canonical_printer_model strips the '# ' clone prefix and the
  851. ' 0.4 nozzle' variant suffix so preset names resolve to a model code."""
  852. def test_strips_nozzle_suffix(self):
  853. assert _canonical_printer_model("Bambu Lab H2D 0.4 nozzle") == "H2D"
  854. def test_strips_clone_prefix_and_suffix(self):
  855. assert _canonical_printer_model("# Bambu Lab X1 Carbon 0.4 nozzle") == "X1C"
  856. def test_bare_model_and_empty(self):
  857. assert _canonical_printer_model("Bambu Lab H2D") == "H2D"
  858. assert _canonical_printer_model(None) is None
  859. assert _canonical_printer_model("") is None
  860. class TestNozzleClassGuard:
  861. """guard_nozzle_class_reslice blocks a re-slice that crosses the
  862. single-nozzle <-> dual-nozzle boundary."""
  863. @pytest.mark.asyncio
  864. async def test_single_to_dual_is_blocked(self, monkeypatch):
  865. import backend.app.api.routes.library as lib
  866. async def _target(_db, _user, _request):
  867. return "H2D"
  868. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  869. with pytest.raises(HTTPException) as exc:
  870. await guard_nozzle_class_reslice(None, None, None, "X1C")
  871. assert exc.value.status_code == 400
  872. assert "H2D" in exc.value.detail and "X1C" in exc.value.detail
  873. @pytest.mark.asyncio
  874. async def test_dual_to_single_is_blocked(self, monkeypatch):
  875. import backend.app.api.routes.library as lib
  876. async def _target(_db, _user, _request):
  877. return "X1C"
  878. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  879. with pytest.raises(HTTPException):
  880. await guard_nozzle_class_reslice(None, None, None, "H2D")
  881. @pytest.mark.asyncio
  882. async def test_same_nozzle_class_is_allowed(self, monkeypatch):
  883. import backend.app.api.routes.library as lib
  884. async def _target(_db, _user, _request):
  885. return "P1S"
  886. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  887. # X1C -> P1S: both single-nozzle — no raise.
  888. await guard_nozzle_class_reslice(None, None, None, "X1C")
  889. @pytest.mark.asyncio
  890. async def test_no_source_model_is_a_noop(self, monkeypatch):
  891. import backend.app.api.routes.library as lib
  892. async def _target(_db, _user, _request):
  893. return "H2D"
  894. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  895. # Un-sliced source (no sliced_for_model) — first-time slice, never blocked.
  896. await guard_nozzle_class_reslice(None, None, None, None)
  897. @pytest.mark.asyncio
  898. @pytest.mark.integration
  899. async def test_archive_reslice_x1c_to_h2d_returns_400(
  900. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  901. ):
  902. """End to end: re-slicing an X1C archive for an H2D printer preset is
  903. rejected synchronously with a 400 — before any job is enqueued."""
  904. tmp_path = slice_test_setup["tmp_path"]
  905. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  906. src_dir = tmp_path / "archives" / "src"
  907. src_dir.mkdir(parents=True, exist_ok=True)
  908. src_3mf = src_dir / "cube.3mf"
  909. src_3mf.write_bytes(_make_3mf_with_settings())
  910. printer = await printer_factory()
  911. source = await archive_factory(
  912. printer.id,
  913. filename="cube.3mf",
  914. file_path=str(src_3mf.relative_to(tmp_path)),
  915. sliced_for_model="X1C",
  916. with_run=False,
  917. )
  918. # A printer preset whose resolved JSON is an H2D — dual-nozzle.
  919. h2d = LocalPreset(
  920. name="# Bambu Lab H2D 0.4 nozzle",
  921. preset_type="printer",
  922. source="orcaslicer",
  923. setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
  924. )
  925. db_session.add(h2d)
  926. await db_session.commit()
  927. await db_session.refresh(h2d)
  928. resp = await async_client.post(
  929. f"/api/v1/archives/{source.id}/slice",
  930. json={
  931. "printer_preset": {"source": "local", "id": str(h2d.id)},
  932. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  933. "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
  934. },
  935. )
  936. assert resp.status_code == 400, resp.text
  937. detail = resp.json()["detail"]
  938. assert "H2D" in detail and "X1C" in detail