test_library_slice_api.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  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.core.config import settings as app_settings
  20. from backend.app.models.library import LibraryFile
  21. from backend.app.models.local_preset import LocalPreset
  22. from backend.app.models.settings import Settings as SettingsModel
  23. from backend.app.services import slicer_api as slicer_api_module
  24. from backend.app.services.slice_dispatch import slice_dispatch
  25. # ---------------------------------------------------------------------------
  26. # Helpers
  27. # ---------------------------------------------------------------------------
  28. def _make_3mf_with_settings(settings_payload: dict | None = None) -> bytes:
  29. """Build a tiny in-memory 3MF zip with all the embedded-config files
  30. that real-world Bambu Studio / OrcaSlicer 3MFs ship with.
  31. The strip-before-forwarding helper has to remove ALL of these (not
  32. just `project_settings.config`) — leftover entries reference printer
  33. / filament IDs from the original slice and trip the CLI's input
  34. validation when a different `--load-settings` triplet is supplied.
  35. """
  36. buf = io.BytesIO()
  37. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  38. zf.writestr("3D/3dmodel.model", "<model/>")
  39. zf.writestr(
  40. "Metadata/project_settings.config",
  41. json.dumps(settings_payload or {"prime_tower_brim_width": "-1"}),
  42. )
  43. zf.writestr("Metadata/model_settings.config", "<config><object id='1'/></config>")
  44. zf.writestr(
  45. "Metadata/slice_info.config",
  46. "<config><plate><metadata key='filament' value='GFL00'/></plate></config>",
  47. )
  48. zf.writestr("Metadata/cut_information.xml", "<cut><part id='1'/></cut>")
  49. return buf.getvalue()
  50. def _install_mock_sidecar(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.AsyncClient:
  51. """Pin a MockTransport-backed httpx client onto the slicer_api singleton
  52. so per-request `SlicerApiService` instances reuse it instead of opening
  53. a real connection."""
  54. client = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=10.0)
  55. slicer_api_module.set_shared_http_client(client)
  56. return client
  57. async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
  58. """Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
  59. The dispatcher runs work as an asyncio task on the same event loop, so
  60. poll-with-sleep here is enough — a few yields and the task finishes.
  61. """
  62. deadline = asyncio.get_event_loop().time() + timeout
  63. while asyncio.get_event_loop().time() < deadline:
  64. r = await client.get(f"/api/v1/slice-jobs/{job_id}")
  65. if r.status_code != 200:
  66. raise AssertionError(f"slice-jobs poll failed: {r.status_code} {r.text}")
  67. body = r.json()
  68. if body["status"] in ("completed", "failed"):
  69. return body
  70. await asyncio.sleep(0.05)
  71. raise AssertionError(f"slice job {job_id} did not finish in {timeout}s")
  72. # ---------------------------------------------------------------------------
  73. # Fixtures
  74. # ---------------------------------------------------------------------------
  75. @pytest.fixture
  76. async def slice_test_setup(db_session, tmp_path):
  77. """Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer."""
  78. storage_dir = tmp_path / "library" / "files"
  79. storage_dir.mkdir(parents=True, exist_ok=True)
  80. src_path = storage_dir / "Cube.stl"
  81. src_path.write_bytes(b"solid Cube\nendsolid\n")
  82. original_base_dir = app_settings.base_dir
  83. app_settings.base_dir = tmp_path
  84. src_file = LibraryFile(
  85. filename="Cube.stl",
  86. file_path=str(src_path.relative_to(tmp_path)),
  87. file_type="stl",
  88. file_size=src_path.stat().st_size,
  89. )
  90. db_session.add(src_file)
  91. presets = {}
  92. for kind in ("printer", "process", "filament"):
  93. p = LocalPreset(
  94. name=f"Test {kind}",
  95. preset_type=kind,
  96. source="orcaslicer",
  97. setting=json.dumps({"name": f"Test {kind}", "type": kind}),
  98. )
  99. db_session.add(p)
  100. presets[kind] = p
  101. db_session.add(SettingsModel(key="preferred_slicer", value="orcaslicer"))
  102. await db_session.commit()
  103. for p in presets.values():
  104. await db_session.refresh(p)
  105. await db_session.refresh(src_file)
  106. yield {
  107. "src_file_id": src_file.id,
  108. "printer_id": presets["printer"].id,
  109. "process_id": presets["process"].id,
  110. "filament_id": presets["filament"].id,
  111. "tmp_path": tmp_path,
  112. }
  113. app_settings.base_dir = original_base_dir
  114. slicer_api_module.set_shared_http_client(None)
  115. # ---------------------------------------------------------------------------
  116. # POST /library/files/{id}/slice — synchronous validation paths
  117. # ---------------------------------------------------------------------------
  118. class TestSliceValidation:
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_returns_404_when_source_missing(self, async_client: AsyncClient, slice_test_setup):
  122. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  123. response = await async_client.post(
  124. "/api/v1/library/files/999999/slice",
  125. json={
  126. "printer_preset_id": slice_test_setup["printer_id"],
  127. "process_preset_id": slice_test_setup["process_id"],
  128. "filament_preset_id": slice_test_setup["filament_id"],
  129. },
  130. )
  131. assert response.status_code == 404
  132. @pytest.mark.asyncio
  133. @pytest.mark.integration
  134. async def test_returns_400_for_wrong_file_type(self, async_client: AsyncClient, db_session, slice_test_setup):
  135. gcode_path = slice_test_setup["tmp_path"] / "library" / "files" / "out.gcode"
  136. gcode_path.write_bytes(b"; gcode\n")
  137. gfile = LibraryFile(
  138. filename="out.gcode",
  139. file_path=str(gcode_path.relative_to(slice_test_setup["tmp_path"])),
  140. file_type="gcode",
  141. file_size=10,
  142. )
  143. db_session.add(gfile)
  144. await db_session.commit()
  145. await db_session.refresh(gfile)
  146. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  147. response = await async_client.post(
  148. f"/api/v1/library/files/{gfile.id}/slice",
  149. json={
  150. "printer_preset_id": slice_test_setup["printer_id"],
  151. "process_preset_id": slice_test_setup["process_id"],
  152. "filament_preset_id": slice_test_setup["filament_id"],
  153. },
  154. )
  155. assert response.status_code == 400
  156. assert "STL, 3MF, or STEP" in response.json()["detail"]
  157. # ---------------------------------------------------------------------------
  158. # POST /library/files/{id}/slice — async dispatch + bg job
  159. # ---------------------------------------------------------------------------
  160. class TestSliceLibraryFile:
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_happy_path_returns_202_then_job_completes_with_library_file(
  164. self, async_client: AsyncClient, slice_test_setup
  165. ):
  166. captured: dict = {}
  167. def handler(request: httpx.Request) -> httpx.Response:
  168. captured["url"] = str(request.url)
  169. return httpx.Response(
  170. status_code=200,
  171. content=b"PK\x03\x04 fake-3mf",
  172. headers={
  173. "x-print-time-seconds": "656",
  174. "x-filament-used-g": "0.94",
  175. "x-filament-used-mm": "302.5",
  176. },
  177. )
  178. _install_mock_sidecar(handler)
  179. response = await async_client.post(
  180. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  181. json={
  182. "printer_preset_id": slice_test_setup["printer_id"],
  183. "process_preset_id": slice_test_setup["process_id"],
  184. "filament_preset_id": slice_test_setup["filament_id"],
  185. },
  186. )
  187. assert response.status_code == 202, response.text
  188. body = response.json()
  189. assert body["status"] == "pending"
  190. assert body["status_url"].startswith("/api/v1/slice-jobs/")
  191. final = await _wait_for_job(async_client, body["job_id"])
  192. assert final["status"] == "completed", final
  193. assert final["result"]["library_file_id"] != slice_test_setup["src_file_id"]
  194. assert final["result"]["print_time_seconds"] == 656
  195. assert captured["url"].endswith("/slice")
  196. @pytest.mark.asyncio
  197. @pytest.mark.integration
  198. async def test_bed_type_override_patches_process_profile(self, async_client: AsyncClient, slice_test_setup):
  199. """#1337: when SliceRequest.bed_type is set, the process JSON sent to
  200. the sidecar must carry curr_bed_type with that exact value. Without
  201. the patch, slicing high-temp filaments on a "Cool Plate" process
  202. preset fails inside the slicer CLI with "does not support filament 1"
  203. and the user has no way to switch plates from the SliceModal."""
  204. captured: dict = {}
  205. def handler(request: httpx.Request) -> httpx.Response:
  206. captured["body"] = bytes(request.content)
  207. return httpx.Response(
  208. status_code=200,
  209. content=b"PK\x03\x04 fake",
  210. headers={
  211. "x-print-time-seconds": "10",
  212. "x-filament-used-g": "0.1",
  213. "x-filament-used-mm": "1.0",
  214. },
  215. )
  216. _install_mock_sidecar(handler)
  217. response = await async_client.post(
  218. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  219. json={
  220. "printer_preset_id": slice_test_setup["printer_id"],
  221. "process_preset_id": slice_test_setup["process_id"],
  222. "filament_preset_id": slice_test_setup["filament_id"],
  223. "bed_type": "Textured PEI Plate",
  224. },
  225. )
  226. assert response.status_code == 202
  227. final = await _wait_for_job(async_client, response.json()["job_id"])
  228. assert final["status"] == "completed", final
  229. # The presetProfile part of the multipart upload now carries the
  230. # override. Searching the raw body avoids parsing the multipart by
  231. # hand — the substring is unique enough since we control the JSON
  232. # being patched.
  233. assert b'"curr_bed_type": "Textured PEI Plate"' in captured["body"], (
  234. "bed_type override must appear in the process JSON sent to the sidecar"
  235. )
  236. @pytest.mark.asyncio
  237. @pytest.mark.integration
  238. async def test_bed_type_omitted_leaves_process_profile_untouched(self, async_client: AsyncClient, slice_test_setup):
  239. """Companion to the override test: the patch must NOT fire when the
  240. client omits bed_type, so the process preset's own curr_bed_type
  241. (or absence thereof) is forwarded to the sidecar unchanged."""
  242. captured: dict = {}
  243. def handler(request: httpx.Request) -> httpx.Response:
  244. captured["body"] = bytes(request.content)
  245. return httpx.Response(
  246. status_code=200,
  247. content=b"PK\x03\x04 fake",
  248. headers={
  249. "x-print-time-seconds": "10",
  250. "x-filament-used-g": "0.1",
  251. "x-filament-used-mm": "1.0",
  252. },
  253. )
  254. _install_mock_sidecar(handler)
  255. response = await async_client.post(
  256. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  257. json={
  258. "printer_preset_id": slice_test_setup["printer_id"],
  259. "process_preset_id": slice_test_setup["process_id"],
  260. "filament_preset_id": slice_test_setup["filament_id"],
  261. },
  262. )
  263. assert response.status_code == 202
  264. final = await _wait_for_job(async_client, response.json()["job_id"])
  265. assert final["status"] == "completed", final
  266. assert b"curr_bed_type" not in captured["body"], (
  267. "bed_type must stay out of the process JSON when no override is set"
  268. )
  269. @pytest.mark.asyncio
  270. @pytest.mark.integration
  271. async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
  272. self, async_client: AsyncClient, slice_test_setup
  273. ):
  274. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  275. response = await async_client.post(
  276. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  277. json={
  278. # Swap printer/filament — both exist but wrong preset_type.
  279. "printer_preset_id": slice_test_setup["filament_id"],
  280. "process_preset_id": slice_test_setup["process_id"],
  281. "filament_preset_id": slice_test_setup["printer_id"],
  282. },
  283. )
  284. assert response.status_code == 202
  285. final = await _wait_for_job(async_client, response.json()["job_id"])
  286. assert final["status"] == "failed"
  287. assert final["error_status"] == 400
  288. assert "preset_type" in (final["error_detail"] or "")
  289. @pytest.mark.asyncio
  290. @pytest.mark.integration
  291. async def test_unknown_preferred_slicer_fails_with_400(
  292. self, async_client: AsyncClient, db_session, slice_test_setup
  293. ):
  294. await db_session.execute(
  295. SettingsModel.__table__.update().where(SettingsModel.key == "preferred_slicer").values(value="prusaslicer")
  296. )
  297. await db_session.commit()
  298. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  299. response = await async_client.post(
  300. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  301. json={
  302. "printer_preset_id": slice_test_setup["printer_id"],
  303. "process_preset_id": slice_test_setup["process_id"],
  304. "filament_preset_id": slice_test_setup["filament_id"],
  305. },
  306. )
  307. assert response.status_code == 202
  308. final = await _wait_for_job(async_client, response.json()["job_id"])
  309. assert final["status"] == "failed"
  310. assert final["error_status"] == 400
  311. assert "preferred_slicer" in (final["error_detail"] or "")
  312. @pytest.mark.asyncio
  313. @pytest.mark.integration
  314. async def test_sidecar_unreachable_fails_with_502(self, async_client: AsyncClient, slice_test_setup):
  315. def handler(_: httpx.Request) -> httpx.Response:
  316. raise httpx.ConnectError("connection refused")
  317. _install_mock_sidecar(handler)
  318. response = await async_client.post(
  319. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  320. json={
  321. "printer_preset_id": slice_test_setup["printer_id"],
  322. "process_preset_id": slice_test_setup["process_id"],
  323. "filament_preset_id": slice_test_setup["filament_id"],
  324. },
  325. )
  326. assert response.status_code == 202
  327. final = await _wait_for_job(async_client, response.json()["job_id"])
  328. assert final["status"] == "failed"
  329. assert final["error_status"] == 502
  330. assert "unreachable" in (final["error_detail"] or "").lower()
  331. @pytest.mark.asyncio
  332. @pytest.mark.integration
  333. async def test_3mf_falls_back_to_embedded_settings_on_cli_failure(
  334. self, async_client: AsyncClient, db_session, slice_test_setup
  335. ):
  336. # When the slicer CLI fails on the --load-settings path (segfault
  337. # on complex H2D models), Bambuddy retries with no profile triplet
  338. # so the CLI uses the file's embedded settings.
  339. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex.3mf"
  340. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  341. threemf = LibraryFile(
  342. filename="complex.3mf",
  343. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  344. file_type="3mf",
  345. file_size=src_3mf_path.stat().st_size,
  346. )
  347. db_session.add(threemf)
  348. await db_session.commit()
  349. await db_session.refresh(threemf)
  350. call_count = {"n": 0}
  351. def handler(request: httpx.Request) -> httpx.Response:
  352. call_count["n"] += 1
  353. # First call: profile triplet present → simulate CLI 5xx
  354. if call_count["n"] == 1:
  355. return httpx.Response(
  356. status_code=500,
  357. json={"message": "Failed to slice the model"},
  358. )
  359. # Retry: no profile triplet → succeed with embedded settings
  360. return httpx.Response(
  361. status_code=200,
  362. content=b"PK\x03\x04 fake-3mf",
  363. headers={
  364. "x-print-time-seconds": "100",
  365. "x-filament-used-g": "1.0",
  366. "x-filament-used-mm": "100",
  367. },
  368. )
  369. _install_mock_sidecar(handler)
  370. response = await async_client.post(
  371. f"/api/v1/library/files/{threemf.id}/slice",
  372. json={
  373. "printer_preset_id": slice_test_setup["printer_id"],
  374. "process_preset_id": slice_test_setup["process_id"],
  375. "filament_preset_id": slice_test_setup["filament_id"],
  376. },
  377. )
  378. assert response.status_code == 202
  379. final = await _wait_for_job(async_client, response.json()["job_id"])
  380. assert final["status"] == "completed", final
  381. assert final["result"]["used_embedded_settings"] is True
  382. assert call_count["n"] == 2 # primary + fallback retry
  383. @pytest.mark.asyncio
  384. @pytest.mark.integration
  385. async def test_stl_does_not_fall_back_on_cli_failure(self, async_client: AsyncClient, slice_test_setup):
  386. # STL has no embedded settings — the CLI 5xx is terminal.
  387. call_count = {"n": 0}
  388. def handler(_: httpx.Request) -> httpx.Response:
  389. call_count["n"] += 1
  390. return httpx.Response(
  391. status_code=500,
  392. json={"message": "Failed to slice the model"},
  393. )
  394. _install_mock_sidecar(handler)
  395. response = await async_client.post(
  396. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  397. json={
  398. "printer_preset_id": slice_test_setup["printer_id"],
  399. "process_preset_id": slice_test_setup["process_id"],
  400. "filament_preset_id": slice_test_setup["filament_id"],
  401. },
  402. )
  403. assert response.status_code == 202
  404. final = await _wait_for_job(async_client, response.json()["job_id"])
  405. assert final["status"] == "failed"
  406. assert final["error_status"] == 502
  407. assert call_count["n"] == 1 # No retry for STL
  408. @pytest.mark.asyncio
  409. @pytest.mark.integration
  410. async def test_3mf_input_forwarded_unmodified_to_sidecar(
  411. self, async_client: AsyncClient, db_session, slice_test_setup
  412. ):
  413. # 3MF input must be forwarded to the sidecar verbatim — every
  414. # Metadata/*.config the source carries (project_settings,
  415. # model_settings, slice_info, cut_information) is needed by the
  416. # CLI to find plate definitions and baseline config; an earlier
  417. # version of this code stripped them and caused the CLI to
  418. # silently exit immediately after "Initializing StaticPrintConfigs"
  419. # for every 3MF slice. --load-settings overrides the specific
  420. # fields the user changed; the rest comes from the embedded data.
  421. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "real.3mf"
  422. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  423. threemf = LibraryFile(
  424. filename="real.3mf",
  425. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  426. file_type="3mf",
  427. file_size=src_3mf_path.stat().st_size,
  428. )
  429. db_session.add(threemf)
  430. await db_session.commit()
  431. await db_session.refresh(threemf)
  432. captured: dict = {}
  433. def handler(request: httpx.Request) -> httpx.Response:
  434. captured["body"] = request.content
  435. return httpx.Response(
  436. status_code=200,
  437. content=b"PK\x03\x04 fake-3mf",
  438. headers={
  439. "x-print-time-seconds": "1",
  440. "x-filament-used-g": "0",
  441. "x-filament-used-mm": "0",
  442. },
  443. )
  444. _install_mock_sidecar(handler)
  445. response = await async_client.post(
  446. f"/api/v1/library/files/{threemf.id}/slice",
  447. json={
  448. "printer_preset_id": slice_test_setup["printer_id"],
  449. "process_preset_id": slice_test_setup["process_id"],
  450. "filament_preset_id": slice_test_setup["filament_id"],
  451. },
  452. )
  453. assert response.status_code == 202
  454. final = await _wait_for_job(async_client, response.json()["job_id"])
  455. assert final["status"] == "completed", final
  456. # Recover the embedded zip from the multipart body and assert ALL
  457. # the source's Metadata/*.config files are still present — the
  458. # opposite of the previous (broken) "strip everything" test.
  459. body = captured["body"]
  460. pk = body.find(b"PK\x03\x04")
  461. assert pk >= 0, "3MF body not found in multipart payload"
  462. with zipfile.ZipFile(io.BytesIO(body[pk:]), "r") as zin:
  463. names = set(zin.namelist())
  464. assert "Metadata/project_settings.config" in names
  465. assert "Metadata/model_settings.config" in names
  466. assert "Metadata/slice_info.config" in names
  467. assert "Metadata/cut_information.xml" in names
  468. assert "3D/3dmodel.model" in names
  469. class TestSliceWithBundle:
  470. """Bundle dispatch path: when SliceRequest.bundle is set, the dispatch
  471. forwards bundle id + per-category preset names to the sidecar instead
  472. of resolving cloud/local/standard PresetRefs. Same fallback semantics
  473. apply for 3MF inputs whose CLI run fails."""
  474. @pytest.mark.asyncio
  475. @pytest.mark.integration
  476. async def test_bundle_dispatch_forwards_form_fields(self, async_client: AsyncClient, slice_test_setup):
  477. captured: dict = {}
  478. def handler(request: httpx.Request) -> httpx.Response:
  479. captured["body"] = request.content
  480. return httpx.Response(
  481. status_code=200,
  482. content=b"PK\x03\x04 fake-3mf",
  483. headers={
  484. "x-print-time-seconds": "200",
  485. "x-filament-used-g": "1.5",
  486. "x-filament-used-mm": "150",
  487. },
  488. )
  489. _install_mock_sidecar(handler)
  490. response = await async_client.post(
  491. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  492. json={
  493. "bundle": {
  494. "bundle_id": "abc123def456abcd",
  495. "printer_name": "# Bambu Lab H2D 0.4 nozzle",
  496. "process_name": "# 0.20mm Standard @BBL H2D",
  497. "filament_names": [
  498. "# Bambu PLA Basic @BBL H2D",
  499. "# Bambu PETG HF @BBL H2D 0.4 nozzle",
  500. ],
  501. },
  502. },
  503. )
  504. assert response.status_code == 202, response.text
  505. final = await _wait_for_job(async_client, response.json()["job_id"])
  506. assert final["status"] == "completed", final
  507. # Multipart form body should carry the bundle selectors instead of
  508. # the JSON profile attachments. Quick string-level check is enough
  509. # to confirm the dispatch picked the bundle branch.
  510. body = captured["body"]
  511. assert b'name="bundle"' in body
  512. assert b"abc123def456abcd" in body
  513. assert b'name="printerName"' in body
  514. assert b'name="processName"' in body
  515. assert b'name="filamentNames"' in body
  516. # Multi-color filament list joined with ';' on the wire.
  517. assert b"# Bambu PLA Basic @BBL H2D;# Bambu PETG HF @BBL H2D 0.4 nozzle" in body
  518. # Profile attachments must NOT be present — bundle dispatch skips
  519. # PresetRef resolution entirely.
  520. assert b'name="printerProfile"' not in body
  521. assert b'name="presetProfile"' not in body
  522. assert b'name="filamentProfile"' not in body
  523. @pytest.mark.asyncio
  524. @pytest.mark.integration
  525. async def test_bundle_dispatch_forwards_bed_type_when_set(self, async_client: AsyncClient, slice_test_setup):
  526. """#1337 follow-up: bed-type override flows through the bundle path
  527. as a `bedType` form field so the sidecar can pass
  528. `--curr_bed_type` to the CLI. Bambuddy can't patch the bundle's
  529. process JSON locally — the sidecar materialises it from the stored
  530. .bbscfg — so the form field is the only handle."""
  531. captured: dict = {}
  532. def handler(request: httpx.Request) -> httpx.Response:
  533. captured["body"] = bytes(request.content)
  534. return httpx.Response(
  535. status_code=200,
  536. content=b"PK\x03\x04 fake",
  537. headers={
  538. "x-print-time-seconds": "10",
  539. "x-filament-used-g": "0.1",
  540. "x-filament-used-mm": "1.0",
  541. },
  542. )
  543. _install_mock_sidecar(handler)
  544. response = await async_client.post(
  545. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  546. json={
  547. "bundle": {
  548. "bundle_id": "abc",
  549. "printer_name": "# X1C",
  550. "process_name": "# 0.20mm",
  551. "filament_names": ["# Bambu PLA"],
  552. },
  553. "bed_type": "Engineering Plate",
  554. },
  555. )
  556. assert response.status_code == 202
  557. final = await _wait_for_job(async_client, response.json()["job_id"])
  558. assert final["status"] == "completed", final
  559. body = captured["body"]
  560. assert b'name="bedType"' in body
  561. assert b"Engineering Plate" in body
  562. @pytest.mark.asyncio
  563. @pytest.mark.integration
  564. async def test_bundle_dispatch_omits_bed_type_when_unset(self, async_client: AsyncClient, slice_test_setup):
  565. """Companion test: no bed_type ⇒ no bedType form field, so the
  566. bundle's own curr_bed_type is preserved end-to-end."""
  567. captured: dict = {}
  568. def handler(request: httpx.Request) -> httpx.Response:
  569. captured["body"] = bytes(request.content)
  570. return httpx.Response(
  571. status_code=200,
  572. content=b"PK\x03\x04 fake",
  573. headers={
  574. "x-print-time-seconds": "10",
  575. "x-filament-used-g": "0.1",
  576. "x-filament-used-mm": "1.0",
  577. },
  578. )
  579. _install_mock_sidecar(handler)
  580. response = await async_client.post(
  581. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  582. json={
  583. "bundle": {
  584. "bundle_id": "abc",
  585. "printer_name": "# X1C",
  586. "process_name": "# 0.20mm",
  587. "filament_names": ["# Bambu PLA"],
  588. },
  589. },
  590. )
  591. assert response.status_code == 202
  592. final = await _wait_for_job(async_client, response.json()["job_id"])
  593. assert final["status"] == "completed", final
  594. assert b'name="bedType"' not in captured["body"]
  595. @pytest.mark.asyncio
  596. @pytest.mark.integration
  597. async def test_bundle_dispatch_3mf_falls_back_to_embedded_on_5xx(
  598. self, async_client: AsyncClient, db_session, slice_test_setup
  599. ):
  600. # Same fallback as the preset-based path: if the resolved bundle
  601. # triplet crashes the CLI on a 3MF, retry with embedded settings
  602. # so the user gets *something* rather than a hard failure.
  603. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex_bundle.3mf"
  604. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  605. threemf = LibraryFile(
  606. filename="complex_bundle.3mf",
  607. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  608. file_type="3mf",
  609. file_size=src_3mf_path.stat().st_size,
  610. )
  611. db_session.add(threemf)
  612. await db_session.commit()
  613. await db_session.refresh(threemf)
  614. call_count = {"n": 0}
  615. def handler(request: httpx.Request) -> httpx.Response:
  616. call_count["n"] += 1
  617. # First call: bundle path → simulate CLI 5xx
  618. if call_count["n"] == 1:
  619. return httpx.Response(
  620. status_code=500,
  621. json={"message": "Failed to slice the model"},
  622. )
  623. # Retry: no profiles / no bundle → succeed with embedded settings
  624. return httpx.Response(
  625. status_code=200,
  626. content=b"PK\x03\x04 fake-3mf",
  627. headers={
  628. "x-print-time-seconds": "100",
  629. "x-filament-used-g": "1.0",
  630. "x-filament-used-mm": "100",
  631. },
  632. )
  633. _install_mock_sidecar(handler)
  634. response = await async_client.post(
  635. f"/api/v1/library/files/{threemf.id}/slice",
  636. json={
  637. "bundle": {
  638. "bundle_id": "abc",
  639. "printer_name": "P",
  640. "process_name": "Q",
  641. "filament_names": ["F"],
  642. },
  643. },
  644. )
  645. assert response.status_code == 202
  646. final = await _wait_for_job(async_client, response.json()["job_id"])
  647. assert final["status"] == "completed", final
  648. assert final["result"]["used_embedded_settings"] is True
  649. assert call_count["n"] == 2 # bundle attempt + embedded fallback
  650. @pytest.mark.asyncio
  651. @pytest.mark.integration
  652. async def test_bundle_dispatch_404_surfaces_as_400(self, async_client: AsyncClient, slice_test_setup):
  653. # Sidecar returns 404 when the bundle / preset name isn't found —
  654. # the slicer client classifies this as user-correctable input
  655. # error so the dispatch returns 400 to the caller, not 502.
  656. def handler(_: httpx.Request) -> httpx.Response:
  657. return httpx.Response(
  658. status_code=404,
  659. json={"message": 'process preset "Imaginary" not found in bundle "abc"'},
  660. )
  661. _install_mock_sidecar(handler)
  662. response = await async_client.post(
  663. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  664. json={
  665. "bundle": {
  666. "bundle_id": "abc",
  667. "printer_name": "P",
  668. "process_name": "Imaginary",
  669. "filament_names": ["F"],
  670. },
  671. },
  672. )
  673. assert response.status_code == 202
  674. final = await _wait_for_job(async_client, response.json()["job_id"])
  675. assert final["status"] == "failed"
  676. assert final["error_status"] == 400
  677. assert "imaginary" in (final["error_detail"] or "").lower()
  678. # ---------------------------------------------------------------------------
  679. # GET /slice-jobs/{id}
  680. # ---------------------------------------------------------------------------
  681. class TestSliceJobs:
  682. @pytest.mark.asyncio
  683. @pytest.mark.integration
  684. async def test_unknown_job_returns_404(self, async_client: AsyncClient):
  685. # Sweep dispatcher state so a fresh ID is unknown.
  686. slice_dispatch._jobs.clear()
  687. r = await async_client.get("/api/v1/slice-jobs/999999")
  688. assert r.status_code == 404