test_slicer_api.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  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. # export_3mf=True → the response body must be a valid 3MF zip, or the
  193. # #2671 output validation rejects it. This test is about the request.
  194. return httpx.Response(
  195. status_code=200,
  196. content=_valid_3mf_zip(),
  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_orient_true_emits_form_field(self):
  266. """#2548: user-requested auto-orient reaches the sidecar as its own
  267. form field, which it turns into ``--orient 1``."""
  268. captured: dict = {}
  269. def handler(request: httpx.Request) -> httpx.Response:
  270. captured["body"] = request.content
  271. return httpx.Response(
  272. status_code=200,
  273. content=b"3MF",
  274. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  275. )
  276. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  277. await service.slice_with_profiles(
  278. model_bytes=b"x",
  279. model_filename="Cube.3mf",
  280. printer_profile_json="{}",
  281. process_profile_json="{}",
  282. filament_profile_jsons=["{}"],
  283. orient=True,
  284. )
  285. assert b'name="orient"' in captured["body"]
  286. @pytest.mark.asyncio
  287. async def test_orient_false_omits_form_field(self):
  288. """An off flag must be expressed by ABSENCE, never by sending
  289. "false". The sidecar branches on ``settings.orient !== undefined``
  290. and multipart fields arrive as strings — and ``"false"`` is truthy
  291. in JavaScript, so sending it would switch auto-orient ON for every
  292. user who left the box unticked."""
  293. captured: dict = {}
  294. def handler(request: httpx.Request) -> httpx.Response:
  295. captured["body"] = request.content
  296. return httpx.Response(
  297. status_code=200,
  298. content=b"3MF",
  299. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  300. )
  301. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  302. await service.slice_with_profiles(
  303. model_bytes=b"x",
  304. model_filename="Cube.3mf",
  305. printer_profile_json="{}",
  306. process_profile_json="{}",
  307. filament_profile_jsons=["{}"],
  308. orient=False,
  309. )
  310. body = captured["body"]
  311. assert b'name="orient"' not in body
  312. assert b"false" not in body
  313. @pytest.mark.asyncio
  314. async def test_profileless_slice_forwards_both_layout_flags(self):
  315. """The embedded-settings path and the segfault fallback both run
  316. through ``slice_without_profiles``. Arrange / orient are CLI actions
  317. on the geometry rather than profile values, so a user's per-slice
  318. choice has to survive those routes too (#2548) — before this they
  319. could not be expressed there at all."""
  320. captured: dict = {}
  321. def handler(request: httpx.Request) -> httpx.Response:
  322. captured["body"] = request.content
  323. return httpx.Response(
  324. status_code=200,
  325. content=b"3MF",
  326. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  327. )
  328. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  329. await service.slice_without_profiles(
  330. model_bytes=b"x",
  331. model_filename="Cube.3mf",
  332. arrange=True,
  333. orient=True,
  334. )
  335. body = captured["body"]
  336. assert b'name="arrange"' in body
  337. assert b'name="orient"' in body
  338. @pytest.mark.asyncio
  339. async def test_profileless_slice_defaults_omit_layout_flags(self):
  340. """The filament-discovery preview also uses this method and passes
  341. neither flag — it must keep sending the pre-#2548 payload, since
  342. rearranging objects would not change which slots a plate consumes
  343. but would burn the arrange pass on every preview."""
  344. captured: dict = {}
  345. def handler(request: httpx.Request) -> httpx.Response:
  346. captured["body"] = request.content
  347. return httpx.Response(
  348. status_code=200,
  349. content=b"3MF",
  350. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  351. )
  352. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  353. await service.slice_without_profiles(model_bytes=b"x", model_filename="Cube.3mf")
  354. body = captured["body"]
  355. assert b'name="arrange"' not in body
  356. assert b'name="orient"' not in body
  357. @pytest.mark.asyncio
  358. async def test_multi_filament_sends_one_part_per_profile(self):
  359. # Multi-color slicing requires N filament profiles, in plate-slot
  360. # order, sent as N repeated multipart `filamentProfile` parts (NOT a
  361. # single concatenated value). The CLI joins their resulting paths
  362. # with `;` for --load-filaments. A future regression to a dict-shaped
  363. # `files=` would silently keep prior tests green but ship only the
  364. # last filament — pin the wire shape.
  365. captured: dict = {}
  366. def handler(request: httpx.Request) -> httpx.Response:
  367. captured["body"] = request.content
  368. return httpx.Response(
  369. status_code=200,
  370. content=b"3MF-BYTES",
  371. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  372. )
  373. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  374. await service.slice_with_profiles(
  375. model_bytes=b"x",
  376. model_filename="Cube.3mf",
  377. printer_profile_json="{}",
  378. process_profile_json="{}",
  379. filament_profile_jsons=['{"a":1}', '{"b":2}', '{"c":3}'],
  380. )
  381. body = captured["body"]
  382. # Three repeated `filamentProfile` parts, in submission order.
  383. assert body.count(b'name="filamentProfile"') == 3
  384. assert b'{"a":1}' in body and b'{"b":2}' in body and b'{"c":3}' in body
  385. # Parts present in plate order — the 'a' bytes appear before 'b'
  386. # which appear before 'c'. (httpx preserves the list order.)
  387. assert body.index(b'{"a":1}') < body.index(b'{"b":2}') < body.index(b'{"c":3}')
  388. @pytest.mark.asyncio
  389. async def test_missing_metadata_headers_default_to_zero(self):
  390. # The /slice endpoint always sets these on success, but be defensive
  391. # so a stripped reverse-proxy or older sidecar doesn't crash callers.
  392. def handler(request: httpx.Request) -> httpx.Response:
  393. return httpx.Response(status_code=200, content=b"; gcode")
  394. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  395. result = await service.slice_with_profiles(
  396. model_bytes=b"x",
  397. model_filename="Cube.stl",
  398. printer_profile_json="{}",
  399. process_profile_json="{}",
  400. filament_profile_jsons=["{}"],
  401. )
  402. assert result.print_time_seconds == 0
  403. assert result.filament_used_g == 0.0
  404. assert result.filament_used_mm == 0.0
  405. def _valid_3mf_zip() -> bytes:
  406. """Minimal-but-valid ZIP so is_zipfile() accepts it as a 3MF container."""
  407. import io
  408. import zipfile
  409. buf = io.BytesIO()
  410. with zipfile.ZipFile(buf, "w") as zf:
  411. zf.writestr("[Content_Types].xml", "<Types/>")
  412. zf.writestr("Metadata/plate_1.gcode", "; G-CODE\nG28\n")
  413. return buf.getvalue()
  414. class TestSliceOutputValidation:
  415. """#2671: a 200 with a non-3MF body must not be persisted as a slice."""
  416. _SLICE_KW = {
  417. "model_bytes": b"solid Cube\n",
  418. "model_filename": "Cube.stl",
  419. "printer_profile_json": "{}",
  420. "process_profile_json": "{}",
  421. "filament_profile_jsons": ["{}"],
  422. }
  423. @pytest.mark.asyncio
  424. async def test_413_gives_actionable_reverse_proxy_message(self):
  425. # A 413 is a proxy/CDN body-size cap, not the slicer — the message must
  426. # point at the right layer so the user stops editing the wrong one.
  427. def handler(request: httpx.Request) -> httpx.Response:
  428. return httpx.Response(status_code=413, content=b"<html>413 Request Entity Too Large</html>")
  429. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  430. with pytest.raises(SlicerInputError) as exc_info:
  431. await service.slice_with_profiles(export_3mf=True, **self._SLICE_KW)
  432. msg = str(exc_info.value)
  433. assert "413" in msg
  434. assert "client_max_body_size" in msg
  435. assert "proxy" in msg.lower()
  436. @pytest.mark.asyncio
  437. async def test_export_3mf_rejects_non_zip_200_body(self):
  438. # The exact failure from #2671: sidecar/proxy returns 200 with a tiny
  439. # garbage body; Bambuddy must NOT accept it as a sliced 3MF.
  440. body = b'{"detail":"Not Found"}xxxxxx' # 28 bytes, not a zip
  441. assert len(body) == 28
  442. def handler(request: httpx.Request) -> httpx.Response:
  443. return httpx.Response(status_code=200, content=body)
  444. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  445. with pytest.raises(SlicerApiServerError) as exc_info:
  446. await service.slice_with_profiles(export_3mf=True, **self._SLICE_KW)
  447. msg = str(exc_info.value)
  448. assert "not a valid" in msg.lower()
  449. assert "28 bytes" in msg
  450. @pytest.mark.asyncio
  451. async def test_export_3mf_accepts_valid_zip_body(self):
  452. zip_bytes = _valid_3mf_zip()
  453. def handler(request: httpx.Request) -> httpx.Response:
  454. return httpx.Response(
  455. status_code=200,
  456. content=zip_bytes,
  457. headers={"x-print-time-seconds": "656"},
  458. )
  459. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  460. result = await service.slice_with_profiles(export_3mf=True, **self._SLICE_KW)
  461. assert result.content == zip_bytes
  462. assert result.print_time_seconds == 656
  463. @pytest.mark.asyncio
  464. async def test_raw_gcode_body_not_zip_validated(self):
  465. # export_3mf defaults False (preview / raw-gcode callers): the body is
  466. # legitimately not a zip, so the validation must NOT fire.
  467. def handler(request: httpx.Request) -> httpx.Response:
  468. return httpx.Response(status_code=200, content=b"; G-CODE\nG28\n")
  469. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  470. result = await service.slice_with_profiles(**self._SLICE_KW)
  471. assert result.content == b"; G-CODE\nG28\n"
  472. @pytest.mark.asyncio
  473. async def test_without_profiles_also_rejects_non_zip_200_body(self):
  474. # The validation lives in the shared response handler, so the
  475. # embedded-settings path (slice_without_profiles) is covered too.
  476. def handler(request: httpx.Request) -> httpx.Response:
  477. return httpx.Response(status_code=200, content=b"nope")
  478. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  479. with pytest.raises(SlicerApiServerError):
  480. await service.slice_without_profiles(
  481. model_bytes=b"solid Cube\n",
  482. model_filename="Cube.stl",
  483. export_3mf=True,
  484. )
  485. class TestHealth:
  486. @pytest.mark.asyncio
  487. async def test_health_returns_body(self):
  488. def handler(request: httpx.Request) -> httpx.Response:
  489. return httpx.Response(
  490. status_code=200,
  491. json={"status": "healthy", "checks": {"orcaslicer": {"available": True}}},
  492. )
  493. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  494. body = await service.health()
  495. assert body["status"] == "healthy"
  496. @pytest.mark.asyncio
  497. async def test_health_unreachable_raises(self):
  498. def handler(request: httpx.Request) -> httpx.Response:
  499. raise httpx.ConnectError("no route")
  500. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  501. with pytest.raises(SlicerApiUnavailableError):
  502. await service.health()
  503. class TestSliceWithProfilesProgress:
  504. """Live-progress wiring for slice_with_profiles.
  505. When the caller supplies a ``request_id`` and an ``on_progress``
  506. callback, the service forwards the id as a ``requestId`` form field
  507. (the sidecar uses it to wire up `--pipe` per request) and spawns a
  508. background poller that calls back into ``on_progress`` for each
  509. snapshot the sidecar publishes. The poller is cancelled the moment
  510. the slice POST returns.
  511. """
  512. @pytest.mark.asyncio
  513. async def test_request_id_forwarded_as_form_field(self):
  514. captured: dict = {}
  515. def handler(request: httpx.Request) -> httpx.Response:
  516. if request.url.path == "/slice":
  517. captured["body"] = request.content
  518. return httpx.Response(
  519. status_code=200,
  520. content=b"PK\x03\x04 fake",
  521. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  522. )
  523. # /slice/progress/<id> — return 404 so the poller exits cleanly.
  524. return httpx.Response(status_code=404, json={"error": "not_found"})
  525. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  526. await service.slice_with_profiles(
  527. model_bytes=b"x",
  528. model_filename="Cube.stl",
  529. printer_profile_json="{}",
  530. process_profile_json="{}",
  531. filament_profile_jsons=["{}"],
  532. request_id="abc-123",
  533. on_progress=lambda _snap: None,
  534. )
  535. # The form field name on the wire is `requestId` (camelCase) to
  536. # match the sidecar's SlicingSettings shape.
  537. body = captured["body"].decode("utf-8", errors="ignore")
  538. assert "requestId" in body
  539. assert "abc-123" in body
  540. @pytest.mark.asyncio
  541. async def test_on_progress_called_with_snapshots(self):
  542. # Drive enough poller ticks for at least one progress 200 to land
  543. # before the slice response unblocks the caller.
  544. slice_release = asyncio.Event()
  545. snapshots: list[dict] = []
  546. async def slice_handler() -> httpx.Response:
  547. # Hold the slice POST until the test signals release, mimicking
  548. # a real long-running slice.
  549. await slice_release.wait()
  550. return httpx.Response(
  551. status_code=200,
  552. content=b"PK\x03\x04",
  553. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  554. )
  555. def handler(request: httpx.Request) -> httpx.Response:
  556. if request.url.path == "/slice":
  557. # MockTransport supports async handlers if we return a
  558. # coroutine — but the simpler path is to drive completion
  559. # via the captured event below.
  560. pass
  561. if request.url.path == "/slice/progress/req-1":
  562. return httpx.Response(
  563. status_code=200,
  564. json={
  565. "stage": "Generating G-code",
  566. "total_percent": 75,
  567. "plate_percent": 80,
  568. "plate_index": 1,
  569. "plate_count": 1,
  570. "updated_at": 0,
  571. },
  572. )
  573. return httpx.Response(404)
  574. # Use an async handler so the slice POST blocks until released.
  575. async def async_handler(request: httpx.Request) -> httpx.Response:
  576. if request.url.path == "/slice":
  577. return await slice_handler()
  578. return handler(request)
  579. client = httpx.AsyncClient(transport=httpx.MockTransport(async_handler))
  580. service = SlicerApiService("http://sidecar:3000", client=client)
  581. # Run the slice with progress callback, releasing it after a beat.
  582. async def release_after_first_snapshot():
  583. # Wait until the poller has published at least one snapshot
  584. # via the on_progress callback, then unblock the slice POST.
  585. for _ in range(60):
  586. if snapshots:
  587. break
  588. await asyncio.sleep(0.05)
  589. slice_release.set()
  590. release_task = asyncio.create_task(release_after_first_snapshot())
  591. try:
  592. await service.slice_with_profiles(
  593. model_bytes=b"x",
  594. model_filename="Cube.stl",
  595. printer_profile_json="{}",
  596. process_profile_json="{}",
  597. filament_profile_jsons=["{}"],
  598. request_id="req-1",
  599. on_progress=lambda snap: snapshots.append(snap),
  600. )
  601. finally:
  602. release_task.cancel()
  603. await asyncio.gather(release_task, return_exceptions=True)
  604. await client.aclose()
  605. assert snapshots, "on_progress was never called"
  606. first = snapshots[0]
  607. assert first["stage"] == "Generating G-code"
  608. assert first["total_percent"] == 75
  609. @pytest.mark.asyncio
  610. async def test_progress_404_does_not_crash_or_stop_polling(self):
  611. """A 404 from /slice/progress/:id is expected during the early
  612. race window (POST fired before sidecar's progressStore.start()
  613. ran) and from older sidecars without progress support. Neither
  614. should crash the slice or block the response — the poller just
  615. keeps trying until the outer cancel fires."""
  616. def handler(request: httpx.Request) -> httpx.Response:
  617. if request.url.path == "/slice":
  618. return httpx.Response(
  619. status_code=200,
  620. content=b"PK\x03\x04",
  621. headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  622. )
  623. return httpx.Response(status_code=404, json={"error": "not_found"})
  624. snapshots: list[dict] = []
  625. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  626. result = await service.slice_with_profiles(
  627. model_bytes=b"x",
  628. model_filename="Cube.stl",
  629. printer_profile_json="{}",
  630. process_profile_json="{}",
  631. filament_profile_jsons=["{}"],
  632. request_id="legacy-sidecar",
  633. on_progress=lambda snap: snapshots.append(snap),
  634. )
  635. assert result is not None
  636. # Sustained 404 → no snapshots ever forwarded.
  637. assert snapshots == []