test_library_slice_api.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  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_invalid_preset_id_surfaces_as_failed_job_with_status_400(
  199. self, async_client: AsyncClient, slice_test_setup
  200. ):
  201. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  202. response = await async_client.post(
  203. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  204. json={
  205. # Swap printer/filament — both exist but wrong preset_type.
  206. "printer_preset_id": slice_test_setup["filament_id"],
  207. "process_preset_id": slice_test_setup["process_id"],
  208. "filament_preset_id": slice_test_setup["printer_id"],
  209. },
  210. )
  211. assert response.status_code == 202
  212. final = await _wait_for_job(async_client, response.json()["job_id"])
  213. assert final["status"] == "failed"
  214. assert final["error_status"] == 400
  215. assert "preset_type" in (final["error_detail"] or "")
  216. @pytest.mark.asyncio
  217. @pytest.mark.integration
  218. async def test_unknown_preferred_slicer_fails_with_400(
  219. self, async_client: AsyncClient, db_session, slice_test_setup
  220. ):
  221. await db_session.execute(
  222. SettingsModel.__table__.update().where(SettingsModel.key == "preferred_slicer").values(value="prusaslicer")
  223. )
  224. await db_session.commit()
  225. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  226. response = await async_client.post(
  227. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  228. json={
  229. "printer_preset_id": slice_test_setup["printer_id"],
  230. "process_preset_id": slice_test_setup["process_id"],
  231. "filament_preset_id": slice_test_setup["filament_id"],
  232. },
  233. )
  234. assert response.status_code == 202
  235. final = await _wait_for_job(async_client, response.json()["job_id"])
  236. assert final["status"] == "failed"
  237. assert final["error_status"] == 400
  238. assert "preferred_slicer" in (final["error_detail"] or "")
  239. @pytest.mark.asyncio
  240. @pytest.mark.integration
  241. async def test_sidecar_unreachable_fails_with_502(self, async_client: AsyncClient, slice_test_setup):
  242. def handler(_: httpx.Request) -> httpx.Response:
  243. raise httpx.ConnectError("connection refused")
  244. _install_mock_sidecar(handler)
  245. response = await async_client.post(
  246. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  247. json={
  248. "printer_preset_id": slice_test_setup["printer_id"],
  249. "process_preset_id": slice_test_setup["process_id"],
  250. "filament_preset_id": slice_test_setup["filament_id"],
  251. },
  252. )
  253. assert response.status_code == 202
  254. final = await _wait_for_job(async_client, response.json()["job_id"])
  255. assert final["status"] == "failed"
  256. assert final["error_status"] == 502
  257. assert "unreachable" in (final["error_detail"] or "").lower()
  258. @pytest.mark.asyncio
  259. @pytest.mark.integration
  260. async def test_3mf_falls_back_to_embedded_settings_on_cli_failure(
  261. self, async_client: AsyncClient, db_session, slice_test_setup
  262. ):
  263. # When the slicer CLI fails on the --load-settings path (segfault
  264. # on complex H2D models), Bambuddy retries with no profile triplet
  265. # so the CLI uses the file's embedded settings.
  266. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex.3mf"
  267. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  268. threemf = LibraryFile(
  269. filename="complex.3mf",
  270. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  271. file_type="3mf",
  272. file_size=src_3mf_path.stat().st_size,
  273. )
  274. db_session.add(threemf)
  275. await db_session.commit()
  276. await db_session.refresh(threemf)
  277. call_count = {"n": 0}
  278. def handler(request: httpx.Request) -> httpx.Response:
  279. call_count["n"] += 1
  280. # First call: profile triplet present → simulate CLI 5xx
  281. if call_count["n"] == 1:
  282. return httpx.Response(
  283. status_code=500,
  284. json={"message": "Failed to slice the model"},
  285. )
  286. # Retry: no profile triplet → succeed with embedded settings
  287. return httpx.Response(
  288. status_code=200,
  289. content=b"PK\x03\x04 fake-3mf",
  290. headers={
  291. "x-print-time-seconds": "100",
  292. "x-filament-used-g": "1.0",
  293. "x-filament-used-mm": "100",
  294. },
  295. )
  296. _install_mock_sidecar(handler)
  297. response = await async_client.post(
  298. f"/api/v1/library/files/{threemf.id}/slice",
  299. json={
  300. "printer_preset_id": slice_test_setup["printer_id"],
  301. "process_preset_id": slice_test_setup["process_id"],
  302. "filament_preset_id": slice_test_setup["filament_id"],
  303. },
  304. )
  305. assert response.status_code == 202
  306. final = await _wait_for_job(async_client, response.json()["job_id"])
  307. assert final["status"] == "completed", final
  308. assert final["result"]["used_embedded_settings"] is True
  309. assert call_count["n"] == 2 # primary + fallback retry
  310. @pytest.mark.asyncio
  311. @pytest.mark.integration
  312. async def test_stl_does_not_fall_back_on_cli_failure(self, async_client: AsyncClient, slice_test_setup):
  313. # STL has no embedded settings — the CLI 5xx is terminal.
  314. call_count = {"n": 0}
  315. def handler(_: httpx.Request) -> httpx.Response:
  316. call_count["n"] += 1
  317. return httpx.Response(
  318. status_code=500,
  319. json={"message": "Failed to slice the model"},
  320. )
  321. _install_mock_sidecar(handler)
  322. response = await async_client.post(
  323. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  324. json={
  325. "printer_preset_id": slice_test_setup["printer_id"],
  326. "process_preset_id": slice_test_setup["process_id"],
  327. "filament_preset_id": slice_test_setup["filament_id"],
  328. },
  329. )
  330. assert response.status_code == 202
  331. final = await _wait_for_job(async_client, response.json()["job_id"])
  332. assert final["status"] == "failed"
  333. assert final["error_status"] == 502
  334. assert call_count["n"] == 1 # No retry for STL
  335. @pytest.mark.asyncio
  336. @pytest.mark.integration
  337. async def test_3mf_input_strips_embedded_settings_before_forwarding(
  338. self, async_client: AsyncClient, db_session, slice_test_setup
  339. ):
  340. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "real.3mf"
  341. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  342. threemf = LibraryFile(
  343. filename="real.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. captured: dict = {}
  352. def handler(request: httpx.Request) -> httpx.Response:
  353. captured["body"] = request.content
  354. return httpx.Response(
  355. status_code=200,
  356. content=b"PK\x03\x04 fake-3mf",
  357. headers={
  358. "x-print-time-seconds": "1",
  359. "x-filament-used-g": "0",
  360. "x-filament-used-mm": "0",
  361. },
  362. )
  363. _install_mock_sidecar(handler)
  364. response = await async_client.post(
  365. f"/api/v1/library/files/{threemf.id}/slice",
  366. json={
  367. "printer_preset_id": slice_test_setup["printer_id"],
  368. "process_preset_id": slice_test_setup["process_id"],
  369. "filament_preset_id": slice_test_setup["filament_id"],
  370. },
  371. )
  372. assert response.status_code == 202
  373. final = await _wait_for_job(async_client, response.json()["job_id"])
  374. assert final["status"] == "completed", final
  375. # Recover the embedded zip from the multipart body — the strip
  376. # must remove every config that references the original slice's
  377. # printer / filament IDs (otherwise the CLI's input validation
  378. # rejects the new --load-settings triplet, the slice fails, and
  379. # we drop into the embedded-settings fallback). Geometry stays.
  380. body = captured["body"]
  381. pk = body.find(b"PK\x03\x04")
  382. assert pk >= 0, "3MF body not found in multipart payload"
  383. with zipfile.ZipFile(io.BytesIO(body[pk:]), "r") as zin:
  384. names = set(zin.namelist())
  385. assert "Metadata/project_settings.config" not in names
  386. assert "Metadata/model_settings.config" not in names
  387. assert "Metadata/slice_info.config" not in names
  388. assert "Metadata/cut_information.xml" not in names
  389. assert "3D/3dmodel.model" in names
  390. # ---------------------------------------------------------------------------
  391. # GET /slice-jobs/{id}
  392. # ---------------------------------------------------------------------------
  393. class TestSliceJobs:
  394. @pytest.mark.asyncio
  395. @pytest.mark.integration
  396. async def test_unknown_job_returns_404(self, async_client: AsyncClient):
  397. # Sweep dispatcher state so a fresh ID is unknown.
  398. slice_dispatch._jobs.clear()
  399. r = await async_client.get("/api/v1/slice-jobs/999999")
  400. assert r.status_code == 404