test_slicer_api.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810
  1. """Tests for SlicerApiService."""
  2. from __future__ import annotations
  3. import asyncio
  4. import httpx
  5. import pytest
  6. from backend.app.services.slicer_api import (
  7. BundleNotFoundError,
  8. BundleSummary,
  9. SlicerApiServerError,
  10. SlicerApiService,
  11. SlicerApiUnavailableError,
  12. SliceResult,
  13. SlicerInputError,
  14. _guess_model_content_type,
  15. )
  16. def _mock_client(handler) -> httpx.AsyncClient:
  17. """Build an httpx.AsyncClient that routes every request through `handler`.
  18. handler signature: (httpx.Request) -> httpx.Response.
  19. """
  20. transport = httpx.MockTransport(handler)
  21. return httpx.AsyncClient(transport=transport, timeout=10.0)
  22. class TestGuessModelContentType:
  23. """The sidecar's multer middleware rejects octet-stream for STL uploads,
  24. so we guess by extension."""
  25. def test_stl(self):
  26. assert _guess_model_content_type("Cube.stl") == "model/stl"
  27. def test_3mf(self):
  28. assert _guess_model_content_type("Bank.3mf") == "model/3mf"
  29. def test_3mf_uppercase(self):
  30. assert _guess_model_content_type("Bank.3MF") == "model/3mf"
  31. def test_step(self):
  32. assert _guess_model_content_type("Cube.step") == "model/step"
  33. def test_stp(self):
  34. assert _guess_model_content_type("Cube.stp") == "model/step"
  35. def test_unknown(self):
  36. assert _guess_model_content_type("foo.bar") == "application/octet-stream"
  37. class TestSliceWithProfiles:
  38. @pytest.mark.asyncio
  39. async def test_happy_path_returns_gcode_and_metadata(self):
  40. captured: dict = {}
  41. def handler(request: httpx.Request) -> httpx.Response:
  42. captured["url"] = str(request.url)
  43. captured["body_len"] = len(request.content)
  44. captured["content_type"] = request.headers.get("content-type", "")
  45. return httpx.Response(
  46. status_code=200,
  47. content=b"; G-CODE START\nG28\n",
  48. headers={
  49. "content-type": "application/octet-stream",
  50. "x-print-time-seconds": "656",
  51. "x-filament-used-g": "0.94",
  52. "x-filament-used-mm": "302.5",
  53. },
  54. )
  55. client = _mock_client(handler)
  56. service = SlicerApiService("http://sidecar:3000", client=client)
  57. result = await service.slice_with_profiles(
  58. model_bytes=b"solid Cube\n",
  59. model_filename="Cube.stl",
  60. printer_profile_json='{"name": "p"}',
  61. process_profile_json='{"name": "pr"}',
  62. filament_profile_jsons=['{"name": "f"}'],
  63. )
  64. assert isinstance(result, SliceResult)
  65. assert result.content == b"; G-CODE START\nG28\n"
  66. assert result.print_time_seconds == 656
  67. assert result.filament_used_g == 0.94
  68. assert result.filament_used_mm == 302.5
  69. assert captured["url"].endswith("/slice")
  70. assert captured["content_type"].startswith("multipart/form-data")
  71. # Roughly: model bytes (>0) + 3 profile JSONs (>0). Sanity check that
  72. # all four parts hit the wire.
  73. assert captured["body_len"] > 0
  74. @pytest.mark.asyncio
  75. async def test_4xx_raises_slicer_input_error(self):
  76. def handler(request: httpx.Request) -> httpx.Response:
  77. return httpx.Response(
  78. status_code=400,
  79. json={"message": "Invalid file type for printerProfile."},
  80. )
  81. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  82. with pytest.raises(SlicerInputError) as exc_info:
  83. await service.slice_with_profiles(
  84. model_bytes=b"x",
  85. model_filename="Cube.stl",
  86. printer_profile_json="{}",
  87. process_profile_json="{}",
  88. filament_profile_jsons=["{}"],
  89. )
  90. assert "Invalid file type" in str(exc_info.value)
  91. @pytest.mark.asyncio
  92. async def test_5xx_raises_server_error(self):
  93. # 5xx from the sidecar = wrapped CLI failed (segfault, range-check
  94. # reject, etc). Distinguished from connection failures so callers
  95. # can retry with a different request shape.
  96. def handler(request: httpx.Request) -> httpx.Response:
  97. return httpx.Response(
  98. status_code=500,
  99. json={"message": "Failed to slice the model"},
  100. )
  101. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  102. with pytest.raises(SlicerApiServerError) as exc_info:
  103. await service.slice_with_profiles(
  104. model_bytes=b"x",
  105. model_filename="Cube.stl",
  106. printer_profile_json="{}",
  107. process_profile_json="{}",
  108. filament_profile_jsons=["{}"],
  109. )
  110. assert "Failed to slice the model" in str(exc_info.value)
  111. @pytest.mark.asyncio
  112. async def test_5xx_includes_sidecar_details_field(self):
  113. """Sidecar's AppError emits ``{message, details}`` — both must end up
  114. in the raised error so ``bambuddy.log`` carries the actual CLI
  115. rejection reason instead of just the generic outer message.
  116. Pinned to fix the regression where every 3MF slice surfaced as
  117. the unhelpful ``Failed to slice the model`` line in production."""
  118. def handler(request: httpx.Request) -> httpx.Response:
  119. return httpx.Response(
  120. status_code=500,
  121. json={
  122. "message": "Failed to slice the model",
  123. "details": "prime_tower_brim_width: -1 not in range [0, 100]",
  124. },
  125. )
  126. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  127. with pytest.raises(SlicerApiServerError) as exc_info:
  128. await service.slice_with_profiles(
  129. model_bytes=b"x",
  130. model_filename="Cube.stl",
  131. printer_profile_json="{}",
  132. process_profile_json="{}",
  133. filament_profile_jsons=["{}"],
  134. )
  135. msg = str(exc_info.value)
  136. assert "Failed to slice the model" in msg
  137. assert "prime_tower_brim_width: -1" in msg
  138. @pytest.mark.asyncio
  139. async def test_5xx_with_only_details_still_surfaces(self):
  140. """If a future sidecar version emits ``details`` without
  141. ``message``, fall back to the details string so we don't end up
  142. with an empty error."""
  143. def handler(request: httpx.Request) -> httpx.Response:
  144. return httpx.Response(
  145. status_code=500,
  146. json={"details": "Slicer killed by SIGSEGV"},
  147. )
  148. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  149. with pytest.raises(SlicerApiServerError) as exc_info:
  150. await service.slice_with_profiles(
  151. model_bytes=b"x",
  152. model_filename="Cube.stl",
  153. printer_profile_json="{}",
  154. process_profile_json="{}",
  155. filament_profile_jsons=["{}"],
  156. )
  157. assert "SIGSEGV" in str(exc_info.value)
  158. @pytest.mark.asyncio
  159. async def test_5xx_with_non_json_body_falls_back_to_text(self):
  160. """Some failure paths (gateway timeouts, bare nginx 502s) return
  161. plain text rather than the JSON envelope. Don't crash trying to
  162. decode it — fall back to the text body."""
  163. def handler(request: httpx.Request) -> httpx.Response:
  164. return httpx.Response(status_code=502, content=b"Bad Gateway")
  165. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  166. with pytest.raises(SlicerApiServerError) as exc_info:
  167. await service.slice_with_profiles(
  168. model_bytes=b"x",
  169. model_filename="Cube.stl",
  170. printer_profile_json="{}",
  171. process_profile_json="{}",
  172. filament_profile_jsons=["{}"],
  173. )
  174. assert "Bad Gateway" in str(exc_info.value)
  175. @pytest.mark.asyncio
  176. async def test_connection_error_raises_unavailable(self):
  177. def handler(request: httpx.Request) -> httpx.Response:
  178. raise httpx.ConnectError("Connection refused")
  179. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  180. with pytest.raises(SlicerApiUnavailableError) as exc_info:
  181. await service.slice_with_profiles(
  182. model_bytes=b"x",
  183. model_filename="Cube.stl",
  184. printer_profile_json="{}",
  185. process_profile_json="{}",
  186. filament_profile_jsons=["{}"],
  187. )
  188. assert "unreachable" in str(exc_info.value).lower()
  189. @pytest.mark.asyncio
  190. async def test_passes_plate_and_export_3mf_options(self):
  191. captured: dict = {}
  192. def handler(request: httpx.Request) -> httpx.Response:
  193. captured["body"] = request.content
  194. return httpx.Response(
  195. status_code=200,
  196. content=b"3MF-BYTES",
  197. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  198. )
  199. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  200. await service.slice_with_profiles(
  201. model_bytes=b"x",
  202. model_filename="Cube.stl",
  203. printer_profile_json="{}",
  204. process_profile_json="{}",
  205. filament_profile_jsons=["{}"],
  206. plate=2,
  207. export_3mf=True,
  208. )
  209. body = captured["body"]
  210. # Multipart body should contain the form fields. Quick membership
  211. # check beats parsing the multipart envelope.
  212. assert b'name="plate"' in body
  213. assert b"\r\n2\r\n" in body or b'name="plate"\r\n\r\n2' in body
  214. assert b'name="exportType"' in body
  215. assert b"3mf" in body
  216. @pytest.mark.asyncio
  217. async def test_arrange_true_emits_form_field(self):
  218. """#1493: cross-class re-slices set arrange=True so BambuStudio
  219. repositions objects for the target bed. The flag must arrive as
  220. a multipart form field the sidecar's SlicingSettings parses."""
  221. captured: dict = {}
  222. def handler(request: httpx.Request) -> httpx.Response:
  223. captured["body"] = request.content
  224. return httpx.Response(
  225. status_code=200,
  226. content=b"3MF",
  227. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  228. )
  229. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  230. await service.slice_with_profiles(
  231. model_bytes=b"x",
  232. model_filename="Cube.3mf",
  233. printer_profile_json="{}",
  234. process_profile_json="{}",
  235. filament_profile_jsons=["{}"],
  236. arrange=True,
  237. )
  238. body = captured["body"]
  239. assert b'name="arrange"' in body
  240. # Sidecar treats non-empty strings as truthy, so "true" suffices.
  241. assert b"true" in body
  242. @pytest.mark.asyncio
  243. async def test_arrange_false_omits_form_field(self):
  244. """Default arrange=False keeps the wire payload identical to the
  245. pre-#1493 shape — no spurious form field that downstream sidecar
  246. versions might mis-parse."""
  247. captured: dict = {}
  248. def handler(request: httpx.Request) -> httpx.Response:
  249. captured["body"] = request.content
  250. return httpx.Response(
  251. status_code=200,
  252. content=b"3MF",
  253. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  254. )
  255. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  256. await service.slice_with_profiles(
  257. model_bytes=b"x",
  258. model_filename="Cube.3mf",
  259. printer_profile_json="{}",
  260. process_profile_json="{}",
  261. filament_profile_jsons=["{}"],
  262. )
  263. assert b'name="arrange"' not in captured["body"]
  264. @pytest.mark.asyncio
  265. async def test_multi_filament_sends_one_part_per_profile(self):
  266. # Multi-color slicing requires N filament profiles, in plate-slot
  267. # order, sent as N repeated multipart `filamentProfile` parts (NOT a
  268. # single concatenated value). The CLI joins their resulting paths
  269. # with `;` for --load-filaments. A future regression to a dict-shaped
  270. # `files=` would silently keep prior tests green but ship only the
  271. # last filament — pin the wire shape.
  272. captured: dict = {}
  273. def handler(request: httpx.Request) -> httpx.Response:
  274. captured["body"] = request.content
  275. return httpx.Response(
  276. status_code=200,
  277. content=b"3MF-BYTES",
  278. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  279. )
  280. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  281. await service.slice_with_profiles(
  282. model_bytes=b"x",
  283. model_filename="Cube.3mf",
  284. printer_profile_json="{}",
  285. process_profile_json="{}",
  286. filament_profile_jsons=['{"a":1}', '{"b":2}', '{"c":3}'],
  287. )
  288. body = captured["body"]
  289. # Three repeated `filamentProfile` parts, in submission order.
  290. assert body.count(b'name="filamentProfile"') == 3
  291. assert b'{"a":1}' in body and b'{"b":2}' in body and b'{"c":3}' in body
  292. # Parts present in plate order — the 'a' bytes appear before 'b'
  293. # which appear before 'c'. (httpx preserves the list order.)
  294. assert body.index(b'{"a":1}') < body.index(b'{"b":2}') < body.index(b'{"c":3}')
  295. @pytest.mark.asyncio
  296. async def test_missing_metadata_headers_default_to_zero(self):
  297. # The /slice endpoint always sets these on success, but be defensive
  298. # so a stripped reverse-proxy or older sidecar doesn't crash callers.
  299. def handler(request: httpx.Request) -> httpx.Response:
  300. return httpx.Response(status_code=200, content=b"; gcode")
  301. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  302. result = await service.slice_with_profiles(
  303. model_bytes=b"x",
  304. model_filename="Cube.stl",
  305. printer_profile_json="{}",
  306. process_profile_json="{}",
  307. filament_profile_jsons=["{}"],
  308. )
  309. assert result.print_time_seconds == 0
  310. assert result.filament_used_g == 0.0
  311. assert result.filament_used_mm == 0.0
  312. class TestHealth:
  313. @pytest.mark.asyncio
  314. async def test_health_returns_body(self):
  315. def handler(request: httpx.Request) -> httpx.Response:
  316. return httpx.Response(
  317. status_code=200,
  318. json={"status": "healthy", "checks": {"orcaslicer": {"available": True}}},
  319. )
  320. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  321. body = await service.health()
  322. assert body["status"] == "healthy"
  323. @pytest.mark.asyncio
  324. async def test_health_unreachable_raises(self):
  325. def handler(request: httpx.Request) -> httpx.Response:
  326. raise httpx.ConnectError("no route")
  327. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  328. with pytest.raises(SlicerApiUnavailableError):
  329. await service.health()
  330. class TestSliceWithProfilesProgress:
  331. """Live-progress wiring for slice_with_profiles.
  332. When the caller supplies a ``request_id`` and an ``on_progress``
  333. callback, the service forwards the id as a ``requestId`` form field
  334. (the sidecar uses it to wire up `--pipe` per request) and spawns a
  335. background poller that calls back into ``on_progress`` for each
  336. snapshot the sidecar publishes. The poller is cancelled the moment
  337. the slice POST returns.
  338. """
  339. @pytest.mark.asyncio
  340. async def test_request_id_forwarded_as_form_field(self):
  341. captured: dict = {}
  342. def handler(request: httpx.Request) -> httpx.Response:
  343. if request.url.path == "/slice":
  344. captured["body"] = request.content
  345. return httpx.Response(
  346. status_code=200,
  347. content=b"PK\x03\x04 fake",
  348. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  349. )
  350. # /slice/progress/<id> — return 404 so the poller exits cleanly.
  351. return httpx.Response(status_code=404, json={"error": "not_found"})
  352. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  353. await service.slice_with_profiles(
  354. model_bytes=b"x",
  355. model_filename="Cube.stl",
  356. printer_profile_json="{}",
  357. process_profile_json="{}",
  358. filament_profile_jsons=["{}"],
  359. request_id="abc-123",
  360. on_progress=lambda _snap: None,
  361. )
  362. # The form field name on the wire is `requestId` (camelCase) to
  363. # match the sidecar's SlicingSettings shape.
  364. body = captured["body"].decode("utf-8", errors="ignore")
  365. assert "requestId" in body
  366. assert "abc-123" in body
  367. @pytest.mark.asyncio
  368. async def test_on_progress_called_with_snapshots(self):
  369. # Drive enough poller ticks for at least one progress 200 to land
  370. # before the slice response unblocks the caller.
  371. slice_release = asyncio.Event()
  372. snapshots: list[dict] = []
  373. async def slice_handler() -> httpx.Response:
  374. # Hold the slice POST until the test signals release, mimicking
  375. # a real long-running slice.
  376. await slice_release.wait()
  377. return httpx.Response(
  378. status_code=200,
  379. content=b"PK\x03\x04",
  380. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  381. )
  382. def handler(request: httpx.Request) -> httpx.Response:
  383. if request.url.path == "/slice":
  384. # MockTransport supports async handlers if we return a
  385. # coroutine — but the simpler path is to drive completion
  386. # via the captured event below.
  387. pass
  388. if request.url.path == "/slice/progress/req-1":
  389. return httpx.Response(
  390. status_code=200,
  391. json={
  392. "stage": "Generating G-code",
  393. "total_percent": 75,
  394. "plate_percent": 80,
  395. "plate_index": 1,
  396. "plate_count": 1,
  397. "updated_at": 0,
  398. },
  399. )
  400. return httpx.Response(404)
  401. # Use an async handler so the slice POST blocks until released.
  402. async def async_handler(request: httpx.Request) -> httpx.Response:
  403. if request.url.path == "/slice":
  404. return await slice_handler()
  405. return handler(request)
  406. client = httpx.AsyncClient(transport=httpx.MockTransport(async_handler))
  407. service = SlicerApiService("http://sidecar:3000", client=client)
  408. # Run the slice with progress callback, releasing it after a beat.
  409. async def release_after_first_snapshot():
  410. # Wait until the poller has published at least one snapshot
  411. # via the on_progress callback, then unblock the slice POST.
  412. for _ in range(60):
  413. if snapshots:
  414. break
  415. await asyncio.sleep(0.05)
  416. slice_release.set()
  417. release_task = asyncio.create_task(release_after_first_snapshot())
  418. try:
  419. await service.slice_with_profiles(
  420. model_bytes=b"x",
  421. model_filename="Cube.stl",
  422. printer_profile_json="{}",
  423. process_profile_json="{}",
  424. filament_profile_jsons=["{}"],
  425. request_id="req-1",
  426. on_progress=lambda snap: snapshots.append(snap),
  427. )
  428. finally:
  429. release_task.cancel()
  430. await asyncio.gather(release_task, return_exceptions=True)
  431. await client.aclose()
  432. assert snapshots, "on_progress was never called"
  433. first = snapshots[0]
  434. assert first["stage"] == "Generating G-code"
  435. assert first["total_percent"] == 75
  436. @pytest.mark.asyncio
  437. async def test_progress_404_does_not_crash_or_stop_polling(self):
  438. """A 404 from /slice/progress/:id is expected during the early
  439. race window (POST fired before sidecar's progressStore.start()
  440. ran) and from older sidecars without progress support. Neither
  441. should crash the slice or block the response — the poller just
  442. keeps trying until the outer cancel fires."""
  443. def handler(request: httpx.Request) -> httpx.Response:
  444. if request.url.path == "/slice":
  445. return httpx.Response(
  446. status_code=200,
  447. content=b"PK\x03\x04",
  448. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  449. )
  450. return httpx.Response(status_code=404, json={"error": "not_found"})
  451. snapshots: list[dict] = []
  452. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  453. result = await service.slice_with_profiles(
  454. model_bytes=b"x",
  455. model_filename="Cube.stl",
  456. printer_profile_json="{}",
  457. process_profile_json="{}",
  458. filament_profile_jsons=["{}"],
  459. request_id="legacy-sidecar",
  460. on_progress=lambda snap: snapshots.append(snap),
  461. )
  462. assert result is not None
  463. # Sustained 404 → no snapshots ever forwarded.
  464. assert snapshots == []
  465. # ── BundleSummary parsing + bundle CRUD client methods ─────────────────────
  466. class TestBundleClientMethods:
  467. """Coverage for import_bundle / list_bundles / get_bundle / delete_bundle.
  468. Mirrors the existing SlicerApiService tests' mock-transport pattern. The
  469. bundle endpoints are simple JSON CRUD on the sidecar, but the response
  470. parsing has to remain forgiving (newer sidecars may add fields, older
  471. ones may omit some) and the failure modes have to map cleanly to our
  472. typed exceptions so route handlers can pick the right HTTP status.
  473. """
  474. SAMPLE_SUMMARY = {
  475. "id": "2bd8722dd20a837e",
  476. "printer_preset_name": "# Bambu Lab H2D 0.4 nozzle",
  477. "printer": ["# Bambu Lab H2D 0.4 nozzle"],
  478. "process": ["# 0.20mm Standard @BBL H2D"],
  479. "filament": ["# Bambu PLA Basic @BBL H2D"],
  480. "version": "02.06.00.50",
  481. }
  482. @pytest.mark.asyncio
  483. async def test_import_bundle_happy_path(self):
  484. captured: dict = {}
  485. def handler(request: httpx.Request) -> httpx.Response:
  486. captured["url"] = str(request.url)
  487. captured["method"] = request.method
  488. captured["content_type"] = request.headers.get("content-type", "")
  489. return httpx.Response(status_code=201, json=self.SAMPLE_SUMMARY)
  490. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  491. summary = await service.import_bundle(b"PK\x03\x04zip-bytes", filename="H2D.bbscfg")
  492. assert isinstance(summary, BundleSummary)
  493. assert summary.id == "2bd8722dd20a837e"
  494. assert summary.printer == ["# Bambu Lab H2D 0.4 nozzle"]
  495. assert summary.process == ["# 0.20mm Standard @BBL H2D"]
  496. assert summary.filament == ["# Bambu PLA Basic @BBL H2D"]
  497. assert captured["method"] == "POST"
  498. assert captured["url"].endswith("/profiles/bundle")
  499. assert captured["content_type"].startswith("multipart/form-data")
  500. @pytest.mark.asyncio
  501. async def test_import_bundle_400_raises_input_error(self):
  502. # Non-.bbscfg uploads, corrupt zips, malicious entry names — all
  503. # rejected by the sidecar with 4xx so the user can fix and retry.
  504. def handler(request: httpx.Request) -> httpx.Response:
  505. return httpx.Response(
  506. status_code=400,
  507. json={"message": "Bundle is missing bundle_structure.json"},
  508. )
  509. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  510. with pytest.raises(SlicerInputError) as exc_info:
  511. await service.import_bundle(b"not a zip")
  512. assert "missing bundle_structure" in str(exc_info.value)
  513. @pytest.mark.asyncio
  514. async def test_import_bundle_5xx_raises_server_error(self):
  515. # Disk-write failure on DATA_PATH — rare but observable when /data
  516. # is a tmpfs that filled up.
  517. def handler(request: httpx.Request) -> httpx.Response:
  518. return httpx.Response(status_code=500, json={"message": "ENOSPC"})
  519. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  520. with pytest.raises(SlicerApiServerError):
  521. await service.import_bundle(b"x")
  522. @pytest.mark.asyncio
  523. async def test_import_bundle_connection_error(self):
  524. def handler(request: httpx.Request) -> httpx.Response:
  525. raise httpx.ConnectError("connection refused")
  526. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  527. with pytest.raises(SlicerApiUnavailableError):
  528. await service.import_bundle(b"x")
  529. @pytest.mark.asyncio
  530. async def test_list_bundles_returns_summaries(self):
  531. def handler(request: httpx.Request) -> httpx.Response:
  532. assert request.url.path == "/profiles/bundles"
  533. return httpx.Response(status_code=200, json=[self.SAMPLE_SUMMARY])
  534. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  535. bundles = await service.list_bundles()
  536. assert len(bundles) == 1
  537. assert bundles[0].id == self.SAMPLE_SUMMARY["id"]
  538. @pytest.mark.asyncio
  539. async def test_list_bundles_empty_array(self):
  540. # Sidecar returns [] when no bundles imported yet — must not raise.
  541. def handler(request: httpx.Request) -> httpx.Response:
  542. return httpx.Response(status_code=200, json=[])
  543. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  544. assert await service.list_bundles() == []
  545. @pytest.mark.asyncio
  546. async def test_list_bundles_non_array_raises(self):
  547. # Older / mis-configured sidecar returning {} instead of []. Surface
  548. # the bug with a clear server error rather than silently treating
  549. # malformed payload as empty.
  550. def handler(request: httpx.Request) -> httpx.Response:
  551. return httpx.Response(status_code=200, json={"unexpected": "shape"})
  552. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  553. with pytest.raises(SlicerApiServerError):
  554. await service.list_bundles()
  555. @pytest.mark.asyncio
  556. async def test_get_bundle_404_raises_not_found(self):
  557. def handler(request: httpx.Request) -> httpx.Response:
  558. return httpx.Response(status_code=404, json={"message": "not found"})
  559. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  560. with pytest.raises(BundleNotFoundError):
  561. await service.get_bundle("deadbeef00000000")
  562. @pytest.mark.asyncio
  563. async def test_get_bundle_happy_path(self):
  564. def handler(request: httpx.Request) -> httpx.Response:
  565. assert request.url.path == "/profiles/bundles/2bd8722dd20a837e"
  566. return httpx.Response(status_code=200, json=self.SAMPLE_SUMMARY)
  567. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  568. summary = await service.get_bundle("2bd8722dd20a837e")
  569. assert summary.id == "2bd8722dd20a837e"
  570. @pytest.mark.asyncio
  571. async def test_delete_bundle_204_succeeds_silently(self):
  572. def handler(request: httpx.Request) -> httpx.Response:
  573. assert request.method == "DELETE"
  574. return httpx.Response(status_code=204)
  575. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  576. # Should not raise.
  577. await service.delete_bundle("2bd8722dd20a837e")
  578. @pytest.mark.asyncio
  579. async def test_delete_bundle_404_raises_not_found(self):
  580. def handler(request: httpx.Request) -> httpx.Response:
  581. return httpx.Response(status_code=404, json={"message": "not found"})
  582. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  583. with pytest.raises(BundleNotFoundError):
  584. await service.delete_bundle("missing")
  585. class TestSliceWithBundle:
  586. """The bundle slice path takes the same model upload but replaces the
  587. profile-attachment fields with bundle-id + preset-name form fields.
  588. Coverage for the form shape, the multi-filament join, and the same
  589. 4xx/5xx mapping as slice_with_profiles."""
  590. @pytest.mark.asyncio
  591. async def test_form_fields_and_filament_join(self):
  592. captured: dict = {}
  593. def handler(request: httpx.Request) -> httpx.Response:
  594. captured["url"] = str(request.url)
  595. captured["body"] = request.content
  596. captured["content_type"] = request.headers.get("content-type", "")
  597. return httpx.Response(
  598. status_code=200,
  599. content=b"; G-CODE",
  600. headers={
  601. "x-print-time-seconds": "60",
  602. "x-filament-used-g": "1.0",
  603. "x-filament-used-mm": "100.0",
  604. },
  605. )
  606. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  607. result = await service.slice_with_bundle(
  608. model_bytes=b"solid Cube\n",
  609. model_filename="Cube.stl",
  610. bundle_id="2bd8722dd20a837e",
  611. printer_name="# Bambu Lab H2D 0.4 nozzle",
  612. process_name="# 0.20mm Standard @BBL H2D",
  613. filament_names=["# Bambu PLA Basic @BBL H2D", "# Bambu PETG HF @BBL H2D"],
  614. )
  615. assert isinstance(result, SliceResult)
  616. assert result.print_time_seconds == 60
  617. assert captured["url"].endswith("/slice")
  618. assert captured["content_type"].startswith("multipart/form-data")
  619. # Multi-filament joined with ';' — the sidecar's parser splits on
  620. # both ';' and ',' so the wire format is the more-explicit ';'.
  621. body = captured["body"]
  622. assert b"# Bambu PLA Basic @BBL H2D;# Bambu PETG HF @BBL H2D" in body
  623. # Each form field appears in the multipart body.
  624. assert b'name="bundle"' in body
  625. assert b'name="printerName"' in body
  626. assert b'name="processName"' in body
  627. assert b'name="filamentNames"' in body
  628. # Bundle id round-trips on the wire.
  629. assert b"2bd8722dd20a837e" in body
  630. @pytest.mark.asyncio
  631. async def test_arrange_true_emits_form_field(self):
  632. """#1493: bundle dispatch also forwards arrange=True so cross-class
  633. slices via .bbscfg bundles get the same BS auto-arrange behaviour
  634. as the preset path."""
  635. captured: dict = {}
  636. def handler(request: httpx.Request) -> httpx.Response:
  637. captured["body"] = request.content
  638. return httpx.Response(
  639. status_code=200,
  640. content=b"3MF",
  641. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  642. )
  643. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  644. await service.slice_with_bundle(
  645. model_bytes=b"x",
  646. model_filename="Cube.3mf",
  647. bundle_id="abc",
  648. printer_name="p",
  649. process_name="pr",
  650. filament_names=["f"],
  651. arrange=True,
  652. )
  653. assert b'name="arrange"' in captured["body"]
  654. @pytest.mark.asyncio
  655. async def test_404_unknown_preset_maps_to_input_error(self):
  656. # Sidecar returns 404 when bundle exists but preset name doesn't.
  657. # The slice route classifies this as user-correctable input error,
  658. # not server failure.
  659. def handler(request: httpx.Request) -> httpx.Response:
  660. return httpx.Response(
  661. status_code=404,
  662. json={"message": 'process preset "Imaginary" not found in bundle "abc"'},
  663. )
  664. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  665. with pytest.raises(SlicerInputError):
  666. await service.slice_with_bundle(
  667. model_bytes=b"x",
  668. model_filename="Cube.stl",
  669. bundle_id="abc",
  670. printer_name="p",
  671. process_name="Imaginary",
  672. filament_names=["f"],
  673. )
  674. @pytest.mark.asyncio
  675. async def test_5xx_maps_to_server_error(self):
  676. # CLI segfault on the resolved triplet — same handling as slice_with_profiles.
  677. def handler(request: httpx.Request) -> httpx.Response:
  678. return httpx.Response(
  679. status_code=500,
  680. json={"message": "Slicer process failed (signal SIGSEGV)"},
  681. )
  682. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  683. with pytest.raises(SlicerApiServerError):
  684. await service.slice_with_bundle(
  685. model_bytes=b"x",
  686. model_filename="Cube.3mf",
  687. bundle_id="abc",
  688. printer_name="p",
  689. process_name="pr",
  690. filament_names=["f"],
  691. )