test_slicer_api.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  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. SlicerApiServerError,
  8. SlicerApiService,
  9. SlicerApiUnavailableError,
  10. SliceResult,
  11. SlicerInputError,
  12. _guess_model_content_type,
  13. )
  14. def _mock_client(handler) -> httpx.AsyncClient:
  15. """Build an httpx.AsyncClient that routes every request through `handler`.
  16. handler signature: (httpx.Request) -> httpx.Response.
  17. """
  18. transport = httpx.MockTransport(handler)
  19. return httpx.AsyncClient(transport=transport, timeout=10.0)
  20. class TestGuessModelContentType:
  21. """The sidecar's multer middleware rejects octet-stream for STL uploads,
  22. so we guess by extension."""
  23. def test_stl(self):
  24. assert _guess_model_content_type("Cube.stl") == "model/stl"
  25. def test_3mf(self):
  26. assert _guess_model_content_type("Bank.3mf") == "model/3mf"
  27. def test_3mf_uppercase(self):
  28. assert _guess_model_content_type("Bank.3MF") == "model/3mf"
  29. def test_step(self):
  30. assert _guess_model_content_type("Cube.step") == "model/step"
  31. def test_stp(self):
  32. assert _guess_model_content_type("Cube.stp") == "model/step"
  33. def test_unknown(self):
  34. assert _guess_model_content_type("foo.bar") == "application/octet-stream"
  35. class TestSliceWithProfiles:
  36. @pytest.mark.asyncio
  37. async def test_happy_path_returns_gcode_and_metadata(self):
  38. captured: dict = {}
  39. def handler(request: httpx.Request) -> httpx.Response:
  40. captured["url"] = str(request.url)
  41. captured["body_len"] = len(request.content)
  42. captured["content_type"] = request.headers.get("content-type", "")
  43. return httpx.Response(
  44. status_code=200,
  45. content=b"; G-CODE START\nG28\n",
  46. headers={
  47. "content-type": "application/octet-stream",
  48. "x-print-time-seconds": "656",
  49. "x-filament-used-g": "0.94",
  50. "x-filament-used-mm": "302.5",
  51. },
  52. )
  53. client = _mock_client(handler)
  54. service = SlicerApiService("http://sidecar:3000", client=client)
  55. result = await service.slice_with_profiles(
  56. model_bytes=b"solid Cube\n",
  57. model_filename="Cube.stl",
  58. printer_profile_json='{"name": "p"}',
  59. process_profile_json='{"name": "pr"}',
  60. filament_profile_jsons=['{"name": "f"}'],
  61. )
  62. assert isinstance(result, SliceResult)
  63. assert result.content == b"; G-CODE START\nG28\n"
  64. assert result.print_time_seconds == 656
  65. assert result.filament_used_g == 0.94
  66. assert result.filament_used_mm == 302.5
  67. assert captured["url"].endswith("/slice")
  68. assert captured["content_type"].startswith("multipart/form-data")
  69. # Roughly: model bytes (>0) + 3 profile JSONs (>0). Sanity check that
  70. # all four parts hit the wire.
  71. assert captured["body_len"] > 0
  72. @pytest.mark.asyncio
  73. async def test_4xx_raises_slicer_input_error(self):
  74. def handler(request: httpx.Request) -> httpx.Response:
  75. return httpx.Response(
  76. status_code=400,
  77. json={"message": "Invalid file type for printerProfile."},
  78. )
  79. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  80. with pytest.raises(SlicerInputError) as exc_info:
  81. await service.slice_with_profiles(
  82. model_bytes=b"x",
  83. model_filename="Cube.stl",
  84. printer_profile_json="{}",
  85. process_profile_json="{}",
  86. filament_profile_jsons=["{}"],
  87. )
  88. assert "Invalid file type" in str(exc_info.value)
  89. @pytest.mark.asyncio
  90. async def test_5xx_raises_server_error(self):
  91. # 5xx from the sidecar = wrapped CLI failed (segfault, range-check
  92. # reject, etc). Distinguished from connection failures so callers
  93. # can retry with a different request shape.
  94. def handler(request: httpx.Request) -> httpx.Response:
  95. return httpx.Response(
  96. status_code=500,
  97. json={"message": "Failed to slice the model"},
  98. )
  99. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  100. with pytest.raises(SlicerApiServerError) as exc_info:
  101. await service.slice_with_profiles(
  102. model_bytes=b"x",
  103. model_filename="Cube.stl",
  104. printer_profile_json="{}",
  105. process_profile_json="{}",
  106. filament_profile_jsons=["{}"],
  107. )
  108. assert "Failed to slice the model" in str(exc_info.value)
  109. @pytest.mark.asyncio
  110. async def test_5xx_includes_sidecar_details_field(self):
  111. """Sidecar's AppError emits ``{message, details}`` — both must end up
  112. in the raised error so ``bambuddy.log`` carries the actual CLI
  113. rejection reason instead of just the generic outer message.
  114. Pinned to fix the regression where every 3MF slice surfaced as
  115. the unhelpful ``Failed to slice the model`` line in production."""
  116. def handler(request: httpx.Request) -> httpx.Response:
  117. return httpx.Response(
  118. status_code=500,
  119. json={
  120. "message": "Failed to slice the model",
  121. "details": "prime_tower_brim_width: -1 not in range [0, 100]",
  122. },
  123. )
  124. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  125. with pytest.raises(SlicerApiServerError) as exc_info:
  126. await service.slice_with_profiles(
  127. model_bytes=b"x",
  128. model_filename="Cube.stl",
  129. printer_profile_json="{}",
  130. process_profile_json="{}",
  131. filament_profile_jsons=["{}"],
  132. )
  133. msg = str(exc_info.value)
  134. assert "Failed to slice the model" in msg
  135. assert "prime_tower_brim_width: -1" in msg
  136. @pytest.mark.asyncio
  137. async def test_5xx_with_only_details_still_surfaces(self):
  138. """If a future sidecar version emits ``details`` without
  139. ``message``, fall back to the details string so we don't end up
  140. with an empty error."""
  141. def handler(request: httpx.Request) -> httpx.Response:
  142. return httpx.Response(
  143. status_code=500,
  144. json={"details": "Slicer killed by SIGSEGV"},
  145. )
  146. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  147. with pytest.raises(SlicerApiServerError) as exc_info:
  148. await service.slice_with_profiles(
  149. model_bytes=b"x",
  150. model_filename="Cube.stl",
  151. printer_profile_json="{}",
  152. process_profile_json="{}",
  153. filament_profile_jsons=["{}"],
  154. )
  155. assert "SIGSEGV" in str(exc_info.value)
  156. @pytest.mark.asyncio
  157. async def test_5xx_with_non_json_body_falls_back_to_text(self):
  158. """Some failure paths (gateway timeouts, bare nginx 502s) return
  159. plain text rather than the JSON envelope. Don't crash trying to
  160. decode it — fall back to the text body."""
  161. def handler(request: httpx.Request) -> httpx.Response:
  162. return httpx.Response(status_code=502, content=b"Bad Gateway")
  163. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  164. with pytest.raises(SlicerApiServerError) as exc_info:
  165. await service.slice_with_profiles(
  166. model_bytes=b"x",
  167. model_filename="Cube.stl",
  168. printer_profile_json="{}",
  169. process_profile_json="{}",
  170. filament_profile_jsons=["{}"],
  171. )
  172. assert "Bad Gateway" in str(exc_info.value)
  173. @pytest.mark.asyncio
  174. async def test_connection_error_raises_unavailable(self):
  175. def handler(request: httpx.Request) -> httpx.Response:
  176. raise httpx.ConnectError("Connection refused")
  177. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  178. with pytest.raises(SlicerApiUnavailableError) as exc_info:
  179. await service.slice_with_profiles(
  180. model_bytes=b"x",
  181. model_filename="Cube.stl",
  182. printer_profile_json="{}",
  183. process_profile_json="{}",
  184. filament_profile_jsons=["{}"],
  185. )
  186. assert "unreachable" in str(exc_info.value).lower()
  187. @pytest.mark.asyncio
  188. async def test_passes_plate_and_export_3mf_options(self):
  189. captured: dict = {}
  190. def handler(request: httpx.Request) -> httpx.Response:
  191. captured["body"] = request.content
  192. return httpx.Response(
  193. status_code=200,
  194. content=b"3MF-BYTES",
  195. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  196. )
  197. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  198. await service.slice_with_profiles(
  199. model_bytes=b"x",
  200. model_filename="Cube.stl",
  201. printer_profile_json="{}",
  202. process_profile_json="{}",
  203. filament_profile_jsons=["{}"],
  204. plate=2,
  205. export_3mf=True,
  206. )
  207. body = captured["body"]
  208. # Multipart body should contain the form fields. Quick membership
  209. # check beats parsing the multipart envelope.
  210. assert b'name="plate"' in body
  211. assert b"\r\n2\r\n" in body or b'name="plate"\r\n\r\n2' in body
  212. assert b'name="exportType"' in body
  213. assert b"3mf" in body
  214. @pytest.mark.asyncio
  215. async def test_multi_filament_sends_one_part_per_profile(self):
  216. # Multi-color slicing requires N filament profiles, in plate-slot
  217. # order, sent as N repeated multipart `filamentProfile` parts (NOT a
  218. # single concatenated value). The CLI joins their resulting paths
  219. # with `;` for --load-filaments. A future regression to a dict-shaped
  220. # `files=` would silently keep prior tests green but ship only the
  221. # last filament — pin the wire shape.
  222. captured: dict = {}
  223. def handler(request: httpx.Request) -> httpx.Response:
  224. captured["body"] = request.content
  225. return httpx.Response(
  226. status_code=200,
  227. content=b"3MF-BYTES",
  228. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  229. )
  230. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  231. await service.slice_with_profiles(
  232. model_bytes=b"x",
  233. model_filename="Cube.3mf",
  234. printer_profile_json="{}",
  235. process_profile_json="{}",
  236. filament_profile_jsons=['{"a":1}', '{"b":2}', '{"c":3}'],
  237. )
  238. body = captured["body"]
  239. # Three repeated `filamentProfile` parts, in submission order.
  240. assert body.count(b'name="filamentProfile"') == 3
  241. assert b'{"a":1}' in body and b'{"b":2}' in body and b'{"c":3}' in body
  242. # Parts present in plate order — the 'a' bytes appear before 'b'
  243. # which appear before 'c'. (httpx preserves the list order.)
  244. assert body.index(b'{"a":1}') < body.index(b'{"b":2}') < body.index(b'{"c":3}')
  245. @pytest.mark.asyncio
  246. async def test_missing_metadata_headers_default_to_zero(self):
  247. # The /slice endpoint always sets these on success, but be defensive
  248. # so a stripped reverse-proxy or older sidecar doesn't crash callers.
  249. def handler(request: httpx.Request) -> httpx.Response:
  250. return httpx.Response(status_code=200, content=b"; gcode")
  251. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  252. result = await service.slice_with_profiles(
  253. model_bytes=b"x",
  254. model_filename="Cube.stl",
  255. printer_profile_json="{}",
  256. process_profile_json="{}",
  257. filament_profile_jsons=["{}"],
  258. )
  259. assert result.print_time_seconds == 0
  260. assert result.filament_used_g == 0.0
  261. assert result.filament_used_mm == 0.0
  262. class TestHealth:
  263. @pytest.mark.asyncio
  264. async def test_health_returns_body(self):
  265. def handler(request: httpx.Request) -> httpx.Response:
  266. return httpx.Response(
  267. status_code=200,
  268. json={"status": "healthy", "checks": {"orcaslicer": {"available": True}}},
  269. )
  270. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  271. body = await service.health()
  272. assert body["status"] == "healthy"
  273. @pytest.mark.asyncio
  274. async def test_health_unreachable_raises(self):
  275. def handler(request: httpx.Request) -> httpx.Response:
  276. raise httpx.ConnectError("no route")
  277. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  278. with pytest.raises(SlicerApiUnavailableError):
  279. await service.health()
  280. class TestSliceWithProfilesProgress:
  281. """Live-progress wiring for slice_with_profiles.
  282. When the caller supplies a ``request_id`` and an ``on_progress``
  283. callback, the service forwards the id as a ``requestId`` form field
  284. (the sidecar uses it to wire up `--pipe` per request) and spawns a
  285. background poller that calls back into ``on_progress`` for each
  286. snapshot the sidecar publishes. The poller is cancelled the moment
  287. the slice POST returns.
  288. """
  289. @pytest.mark.asyncio
  290. async def test_request_id_forwarded_as_form_field(self):
  291. captured: dict = {}
  292. def handler(request: httpx.Request) -> httpx.Response:
  293. if request.url.path == "/slice":
  294. captured["body"] = request.content
  295. return httpx.Response(
  296. status_code=200,
  297. content=b"PK\x03\x04 fake",
  298. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  299. )
  300. # /slice/progress/<id> — return 404 so the poller exits cleanly.
  301. return httpx.Response(status_code=404, json={"error": "not_found"})
  302. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  303. await service.slice_with_profiles(
  304. model_bytes=b"x",
  305. model_filename="Cube.stl",
  306. printer_profile_json="{}",
  307. process_profile_json="{}",
  308. filament_profile_jsons=["{}"],
  309. request_id="abc-123",
  310. on_progress=lambda _snap: None,
  311. )
  312. # The form field name on the wire is `requestId` (camelCase) to
  313. # match the sidecar's SlicingSettings shape.
  314. body = captured["body"].decode("utf-8", errors="ignore")
  315. assert "requestId" in body
  316. assert "abc-123" in body
  317. @pytest.mark.asyncio
  318. async def test_on_progress_called_with_snapshots(self):
  319. # Drive enough poller ticks for at least one progress 200 to land
  320. # before the slice response unblocks the caller.
  321. slice_release = asyncio.Event()
  322. snapshots: list[dict] = []
  323. async def slice_handler() -> httpx.Response:
  324. # Hold the slice POST until the test signals release, mimicking
  325. # a real long-running slice.
  326. await slice_release.wait()
  327. return httpx.Response(
  328. status_code=200,
  329. content=b"PK\x03\x04",
  330. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  331. )
  332. def handler(request: httpx.Request) -> httpx.Response:
  333. if request.url.path == "/slice":
  334. # MockTransport supports async handlers if we return a
  335. # coroutine — but the simpler path is to drive completion
  336. # via the captured event below.
  337. pass
  338. if request.url.path == "/slice/progress/req-1":
  339. return httpx.Response(
  340. status_code=200,
  341. json={
  342. "stage": "Generating G-code",
  343. "total_percent": 75,
  344. "plate_percent": 80,
  345. "plate_index": 1,
  346. "plate_count": 1,
  347. "updated_at": 0,
  348. },
  349. )
  350. return httpx.Response(404)
  351. # Use an async handler so the slice POST blocks until released.
  352. async def async_handler(request: httpx.Request) -> httpx.Response:
  353. if request.url.path == "/slice":
  354. return await slice_handler()
  355. return handler(request)
  356. client = httpx.AsyncClient(transport=httpx.MockTransport(async_handler))
  357. service = SlicerApiService("http://sidecar:3000", client=client)
  358. # Run the slice with progress callback, releasing it after a beat.
  359. async def release_after_first_snapshot():
  360. # Wait until the poller has published at least one snapshot
  361. # via the on_progress callback, then unblock the slice POST.
  362. for _ in range(60):
  363. if snapshots:
  364. break
  365. await asyncio.sleep(0.05)
  366. slice_release.set()
  367. release_task = asyncio.create_task(release_after_first_snapshot())
  368. try:
  369. await service.slice_with_profiles(
  370. model_bytes=b"x",
  371. model_filename="Cube.stl",
  372. printer_profile_json="{}",
  373. process_profile_json="{}",
  374. filament_profile_jsons=["{}"],
  375. request_id="req-1",
  376. on_progress=lambda snap: snapshots.append(snap),
  377. )
  378. finally:
  379. release_task.cancel()
  380. await asyncio.gather(release_task, return_exceptions=True)
  381. await client.aclose()
  382. assert snapshots, "on_progress was never called"
  383. first = snapshots[0]
  384. assert first["stage"] == "Generating G-code"
  385. assert first["total_percent"] == 75
  386. @pytest.mark.asyncio
  387. async def test_progress_404_does_not_crash_or_stop_polling(self):
  388. """A 404 from /slice/progress/:id is expected during the early
  389. race window (POST fired before sidecar's progressStore.start()
  390. ran) and from older sidecars without progress support. Neither
  391. should crash the slice or block the response — the poller just
  392. keeps trying until the outer cancel fires."""
  393. def handler(request: httpx.Request) -> httpx.Response:
  394. if request.url.path == "/slice":
  395. return httpx.Response(
  396. status_code=200,
  397. content=b"PK\x03\x04",
  398. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  399. )
  400. return httpx.Response(status_code=404, json={"error": "not_found"})
  401. snapshots: list[dict] = []
  402. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  403. result = await service.slice_with_profiles(
  404. model_bytes=b"x",
  405. model_filename="Cube.stl",
  406. printer_profile_json="{}",
  407. process_profile_json="{}",
  408. filament_profile_jsons=["{}"],
  409. request_id="legacy-sidecar",
  410. on_progress=lambda snap: snapshots.append(snap),
  411. )
  412. assert result is not None
  413. # Sustained 404 → no snapshots ever forwarded.
  414. assert snapshots == []