test_slicer_api.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  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_multi_filament_sends_one_part_per_profile(self):
  218. # Multi-color slicing requires N filament profiles, in plate-slot
  219. # order, sent as N repeated multipart `filamentProfile` parts (NOT a
  220. # single concatenated value). The CLI joins their resulting paths
  221. # with `;` for --load-filaments. A future regression to a dict-shaped
  222. # `files=` would silently keep prior tests green but ship only the
  223. # last filament — pin the wire shape.
  224. captured: dict = {}
  225. def handler(request: httpx.Request) -> httpx.Response:
  226. captured["body"] = request.content
  227. return httpx.Response(
  228. status_code=200,
  229. content=b"3MF-BYTES",
  230. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  231. )
  232. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  233. await service.slice_with_profiles(
  234. model_bytes=b"x",
  235. model_filename="Cube.3mf",
  236. printer_profile_json="{}",
  237. process_profile_json="{}",
  238. filament_profile_jsons=['{"a":1}', '{"b":2}', '{"c":3}'],
  239. )
  240. body = captured["body"]
  241. # Three repeated `filamentProfile` parts, in submission order.
  242. assert body.count(b'name="filamentProfile"') == 3
  243. assert b'{"a":1}' in body and b'{"b":2}' in body and b'{"c":3}' in body
  244. # Parts present in plate order — the 'a' bytes appear before 'b'
  245. # which appear before 'c'. (httpx preserves the list order.)
  246. assert body.index(b'{"a":1}') < body.index(b'{"b":2}') < body.index(b'{"c":3}')
  247. @pytest.mark.asyncio
  248. async def test_missing_metadata_headers_default_to_zero(self):
  249. # The /slice endpoint always sets these on success, but be defensive
  250. # so a stripped reverse-proxy or older sidecar doesn't crash callers.
  251. def handler(request: httpx.Request) -> httpx.Response:
  252. return httpx.Response(status_code=200, content=b"; gcode")
  253. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  254. result = await service.slice_with_profiles(
  255. model_bytes=b"x",
  256. model_filename="Cube.stl",
  257. printer_profile_json="{}",
  258. process_profile_json="{}",
  259. filament_profile_jsons=["{}"],
  260. )
  261. assert result.print_time_seconds == 0
  262. assert result.filament_used_g == 0.0
  263. assert result.filament_used_mm == 0.0
  264. class TestHealth:
  265. @pytest.mark.asyncio
  266. async def test_health_returns_body(self):
  267. def handler(request: httpx.Request) -> httpx.Response:
  268. return httpx.Response(
  269. status_code=200,
  270. json={"status": "healthy", "checks": {"orcaslicer": {"available": True}}},
  271. )
  272. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  273. body = await service.health()
  274. assert body["status"] == "healthy"
  275. @pytest.mark.asyncio
  276. async def test_health_unreachable_raises(self):
  277. def handler(request: httpx.Request) -> httpx.Response:
  278. raise httpx.ConnectError("no route")
  279. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  280. with pytest.raises(SlicerApiUnavailableError):
  281. await service.health()
  282. class TestSliceWithProfilesProgress:
  283. """Live-progress wiring for slice_with_profiles.
  284. When the caller supplies a ``request_id`` and an ``on_progress``
  285. callback, the service forwards the id as a ``requestId`` form field
  286. (the sidecar uses it to wire up `--pipe` per request) and spawns a
  287. background poller that calls back into ``on_progress`` for each
  288. snapshot the sidecar publishes. The poller is cancelled the moment
  289. the slice POST returns.
  290. """
  291. @pytest.mark.asyncio
  292. async def test_request_id_forwarded_as_form_field(self):
  293. captured: dict = {}
  294. def handler(request: httpx.Request) -> httpx.Response:
  295. if request.url.path == "/slice":
  296. captured["body"] = request.content
  297. return httpx.Response(
  298. status_code=200,
  299. content=b"PK\x03\x04 fake",
  300. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  301. )
  302. # /slice/progress/<id> — return 404 so the poller exits cleanly.
  303. return httpx.Response(status_code=404, json={"error": "not_found"})
  304. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  305. await service.slice_with_profiles(
  306. model_bytes=b"x",
  307. model_filename="Cube.stl",
  308. printer_profile_json="{}",
  309. process_profile_json="{}",
  310. filament_profile_jsons=["{}"],
  311. request_id="abc-123",
  312. on_progress=lambda _snap: None,
  313. )
  314. # The form field name on the wire is `requestId` (camelCase) to
  315. # match the sidecar's SlicingSettings shape.
  316. body = captured["body"].decode("utf-8", errors="ignore")
  317. assert "requestId" in body
  318. assert "abc-123" in body
  319. @pytest.mark.asyncio
  320. async def test_on_progress_called_with_snapshots(self):
  321. # Drive enough poller ticks for at least one progress 200 to land
  322. # before the slice response unblocks the caller.
  323. slice_release = asyncio.Event()
  324. snapshots: list[dict] = []
  325. async def slice_handler() -> httpx.Response:
  326. # Hold the slice POST until the test signals release, mimicking
  327. # a real long-running slice.
  328. await slice_release.wait()
  329. return httpx.Response(
  330. status_code=200,
  331. content=b"PK\x03\x04",
  332. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  333. )
  334. def handler(request: httpx.Request) -> httpx.Response:
  335. if request.url.path == "/slice":
  336. # MockTransport supports async handlers if we return a
  337. # coroutine — but the simpler path is to drive completion
  338. # via the captured event below.
  339. pass
  340. if request.url.path == "/slice/progress/req-1":
  341. return httpx.Response(
  342. status_code=200,
  343. json={
  344. "stage": "Generating G-code",
  345. "total_percent": 75,
  346. "plate_percent": 80,
  347. "plate_index": 1,
  348. "plate_count": 1,
  349. "updated_at": 0,
  350. },
  351. )
  352. return httpx.Response(404)
  353. # Use an async handler so the slice POST blocks until released.
  354. async def async_handler(request: httpx.Request) -> httpx.Response:
  355. if request.url.path == "/slice":
  356. return await slice_handler()
  357. return handler(request)
  358. client = httpx.AsyncClient(transport=httpx.MockTransport(async_handler))
  359. service = SlicerApiService("http://sidecar:3000", client=client)
  360. # Run the slice with progress callback, releasing it after a beat.
  361. async def release_after_first_snapshot():
  362. # Wait until the poller has published at least one snapshot
  363. # via the on_progress callback, then unblock the slice POST.
  364. for _ in range(60):
  365. if snapshots:
  366. break
  367. await asyncio.sleep(0.05)
  368. slice_release.set()
  369. release_task = asyncio.create_task(release_after_first_snapshot())
  370. try:
  371. await service.slice_with_profiles(
  372. model_bytes=b"x",
  373. model_filename="Cube.stl",
  374. printer_profile_json="{}",
  375. process_profile_json="{}",
  376. filament_profile_jsons=["{}"],
  377. request_id="req-1",
  378. on_progress=lambda snap: snapshots.append(snap),
  379. )
  380. finally:
  381. release_task.cancel()
  382. await asyncio.gather(release_task, return_exceptions=True)
  383. await client.aclose()
  384. assert snapshots, "on_progress was never called"
  385. first = snapshots[0]
  386. assert first["stage"] == "Generating G-code"
  387. assert first["total_percent"] == 75
  388. @pytest.mark.asyncio
  389. async def test_progress_404_does_not_crash_or_stop_polling(self):
  390. """A 404 from /slice/progress/:id is expected during the early
  391. race window (POST fired before sidecar's progressStore.start()
  392. ran) and from older sidecars without progress support. Neither
  393. should crash the slice or block the response — the poller just
  394. keeps trying until the outer cancel fires."""
  395. def handler(request: httpx.Request) -> httpx.Response:
  396. if request.url.path == "/slice":
  397. return httpx.Response(
  398. status_code=200,
  399. content=b"PK\x03\x04",
  400. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  401. )
  402. return httpx.Response(status_code=404, json={"error": "not_found"})
  403. snapshots: list[dict] = []
  404. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  405. result = await service.slice_with_profiles(
  406. model_bytes=b"x",
  407. model_filename="Cube.stl",
  408. printer_profile_json="{}",
  409. process_profile_json="{}",
  410. filament_profile_jsons=["{}"],
  411. request_id="legacy-sidecar",
  412. on_progress=lambda snap: snapshots.append(snap),
  413. )
  414. assert result is not None
  415. # Sustained 404 → no snapshots ever forwarded.
  416. assert snapshots == []
  417. # ── BundleSummary parsing + bundle CRUD client methods ─────────────────────
  418. class TestBundleClientMethods:
  419. """Coverage for import_bundle / list_bundles / get_bundle / delete_bundle.
  420. Mirrors the existing SlicerApiService tests' mock-transport pattern. The
  421. bundle endpoints are simple JSON CRUD on the sidecar, but the response
  422. parsing has to remain forgiving (newer sidecars may add fields, older
  423. ones may omit some) and the failure modes have to map cleanly to our
  424. typed exceptions so route handlers can pick the right HTTP status.
  425. """
  426. SAMPLE_SUMMARY = {
  427. "id": "2bd8722dd20a837e",
  428. "printer_preset_name": "# Bambu Lab H2D 0.4 nozzle",
  429. "printer": ["# Bambu Lab H2D 0.4 nozzle"],
  430. "process": ["# 0.20mm Standard @BBL H2D"],
  431. "filament": ["# Bambu PLA Basic @BBL H2D"],
  432. "version": "02.06.00.50",
  433. }
  434. @pytest.mark.asyncio
  435. async def test_import_bundle_happy_path(self):
  436. captured: dict = {}
  437. def handler(request: httpx.Request) -> httpx.Response:
  438. captured["url"] = str(request.url)
  439. captured["method"] = request.method
  440. captured["content_type"] = request.headers.get("content-type", "")
  441. return httpx.Response(status_code=201, json=self.SAMPLE_SUMMARY)
  442. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  443. summary = await service.import_bundle(b"PK\x03\x04zip-bytes", filename="H2D.bbscfg")
  444. assert isinstance(summary, BundleSummary)
  445. assert summary.id == "2bd8722dd20a837e"
  446. assert summary.printer == ["# Bambu Lab H2D 0.4 nozzle"]
  447. assert summary.process == ["# 0.20mm Standard @BBL H2D"]
  448. assert summary.filament == ["# Bambu PLA Basic @BBL H2D"]
  449. assert captured["method"] == "POST"
  450. assert captured["url"].endswith("/profiles/bundle")
  451. assert captured["content_type"].startswith("multipart/form-data")
  452. @pytest.mark.asyncio
  453. async def test_import_bundle_400_raises_input_error(self):
  454. # Non-.bbscfg uploads, corrupt zips, malicious entry names — all
  455. # rejected by the sidecar with 4xx so the user can fix and retry.
  456. def handler(request: httpx.Request) -> httpx.Response:
  457. return httpx.Response(
  458. status_code=400,
  459. json={"message": "Bundle is missing bundle_structure.json"},
  460. )
  461. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  462. with pytest.raises(SlicerInputError) as exc_info:
  463. await service.import_bundle(b"not a zip")
  464. assert "missing bundle_structure" in str(exc_info.value)
  465. @pytest.mark.asyncio
  466. async def test_import_bundle_5xx_raises_server_error(self):
  467. # Disk-write failure on DATA_PATH — rare but observable when /data
  468. # is a tmpfs that filled up.
  469. def handler(request: httpx.Request) -> httpx.Response:
  470. return httpx.Response(status_code=500, json={"message": "ENOSPC"})
  471. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  472. with pytest.raises(SlicerApiServerError):
  473. await service.import_bundle(b"x")
  474. @pytest.mark.asyncio
  475. async def test_import_bundle_connection_error(self):
  476. def handler(request: httpx.Request) -> httpx.Response:
  477. raise httpx.ConnectError("connection refused")
  478. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  479. with pytest.raises(SlicerApiUnavailableError):
  480. await service.import_bundle(b"x")
  481. @pytest.mark.asyncio
  482. async def test_list_bundles_returns_summaries(self):
  483. def handler(request: httpx.Request) -> httpx.Response:
  484. assert request.url.path == "/profiles/bundles"
  485. return httpx.Response(status_code=200, json=[self.SAMPLE_SUMMARY])
  486. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  487. bundles = await service.list_bundles()
  488. assert len(bundles) == 1
  489. assert bundles[0].id == self.SAMPLE_SUMMARY["id"]
  490. @pytest.mark.asyncio
  491. async def test_list_bundles_empty_array(self):
  492. # Sidecar returns [] when no bundles imported yet — must not raise.
  493. def handler(request: httpx.Request) -> httpx.Response:
  494. return httpx.Response(status_code=200, json=[])
  495. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  496. assert await service.list_bundles() == []
  497. @pytest.mark.asyncio
  498. async def test_list_bundles_non_array_raises(self):
  499. # Older / mis-configured sidecar returning {} instead of []. Surface
  500. # the bug with a clear server error rather than silently treating
  501. # malformed payload as empty.
  502. def handler(request: httpx.Request) -> httpx.Response:
  503. return httpx.Response(status_code=200, json={"unexpected": "shape"})
  504. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  505. with pytest.raises(SlicerApiServerError):
  506. await service.list_bundles()
  507. @pytest.mark.asyncio
  508. async def test_get_bundle_404_raises_not_found(self):
  509. def handler(request: httpx.Request) -> httpx.Response:
  510. return httpx.Response(status_code=404, json={"message": "not found"})
  511. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  512. with pytest.raises(BundleNotFoundError):
  513. await service.get_bundle("deadbeef00000000")
  514. @pytest.mark.asyncio
  515. async def test_get_bundle_happy_path(self):
  516. def handler(request: httpx.Request) -> httpx.Response:
  517. assert request.url.path == "/profiles/bundles/2bd8722dd20a837e"
  518. return httpx.Response(status_code=200, json=self.SAMPLE_SUMMARY)
  519. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  520. summary = await service.get_bundle("2bd8722dd20a837e")
  521. assert summary.id == "2bd8722dd20a837e"
  522. @pytest.mark.asyncio
  523. async def test_delete_bundle_204_succeeds_silently(self):
  524. def handler(request: httpx.Request) -> httpx.Response:
  525. assert request.method == "DELETE"
  526. return httpx.Response(status_code=204)
  527. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  528. # Should not raise.
  529. await service.delete_bundle("2bd8722dd20a837e")
  530. @pytest.mark.asyncio
  531. async def test_delete_bundle_404_raises_not_found(self):
  532. def handler(request: httpx.Request) -> httpx.Response:
  533. return httpx.Response(status_code=404, json={"message": "not found"})
  534. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  535. with pytest.raises(BundleNotFoundError):
  536. await service.delete_bundle("missing")
  537. class TestSliceWithBundle:
  538. """The bundle slice path takes the same model upload but replaces the
  539. profile-attachment fields with bundle-id + preset-name form fields.
  540. Coverage for the form shape, the multi-filament join, and the same
  541. 4xx/5xx mapping as slice_with_profiles."""
  542. @pytest.mark.asyncio
  543. async def test_form_fields_and_filament_join(self):
  544. captured: dict = {}
  545. def handler(request: httpx.Request) -> httpx.Response:
  546. captured["url"] = str(request.url)
  547. captured["body"] = request.content
  548. captured["content_type"] = request.headers.get("content-type", "")
  549. return httpx.Response(
  550. status_code=200,
  551. content=b"; G-CODE",
  552. headers={
  553. "x-print-time-seconds": "60",
  554. "x-filament-used-g": "1.0",
  555. "x-filament-used-mm": "100.0",
  556. },
  557. )
  558. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  559. result = await service.slice_with_bundle(
  560. model_bytes=b"solid Cube\n",
  561. model_filename="Cube.stl",
  562. bundle_id="2bd8722dd20a837e",
  563. printer_name="# Bambu Lab H2D 0.4 nozzle",
  564. process_name="# 0.20mm Standard @BBL H2D",
  565. filament_names=["# Bambu PLA Basic @BBL H2D", "# Bambu PETG HF @BBL H2D"],
  566. )
  567. assert isinstance(result, SliceResult)
  568. assert result.print_time_seconds == 60
  569. assert captured["url"].endswith("/slice")
  570. assert captured["content_type"].startswith("multipart/form-data")
  571. # Multi-filament joined with ';' — the sidecar's parser splits on
  572. # both ';' and ',' so the wire format is the more-explicit ';'.
  573. body = captured["body"]
  574. assert b"# Bambu PLA Basic @BBL H2D;# Bambu PETG HF @BBL H2D" in body
  575. # Each form field appears in the multipart body.
  576. assert b'name="bundle"' in body
  577. assert b'name="printerName"' in body
  578. assert b'name="processName"' in body
  579. assert b'name="filamentNames"' in body
  580. # Bundle id round-trips on the wire.
  581. assert b"2bd8722dd20a837e" in body
  582. @pytest.mark.asyncio
  583. async def test_404_unknown_preset_maps_to_input_error(self):
  584. # Sidecar returns 404 when bundle exists but preset name doesn't.
  585. # The slice route classifies this as user-correctable input error,
  586. # not server failure.
  587. def handler(request: httpx.Request) -> httpx.Response:
  588. return httpx.Response(
  589. status_code=404,
  590. json={"message": 'process preset "Imaginary" not found in bundle "abc"'},
  591. )
  592. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  593. with pytest.raises(SlicerInputError):
  594. await service.slice_with_bundle(
  595. model_bytes=b"x",
  596. model_filename="Cube.stl",
  597. bundle_id="abc",
  598. printer_name="p",
  599. process_name="Imaginary",
  600. filament_names=["f"],
  601. )
  602. @pytest.mark.asyncio
  603. async def test_5xx_maps_to_server_error(self):
  604. # CLI segfault on the resolved triplet — same handling as slice_with_profiles.
  605. def handler(request: httpx.Request) -> httpx.Response:
  606. return httpx.Response(
  607. status_code=500,
  608. json={"message": "Slicer process failed (signal SIGSEGV)"},
  609. )
  610. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  611. with pytest.raises(SlicerApiServerError):
  612. await service.slice_with_bundle(
  613. model_bytes=b"x",
  614. model_filename="Cube.3mf",
  615. bundle_id="abc",
  616. printer_name="p",
  617. process_name="pr",
  618. filament_names=["f"],
  619. )