test_slicer_api.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. """Tests for SlicerApiService."""
  2. from __future__ import annotations
  3. import httpx
  4. import pytest
  5. from backend.app.services.slicer_api import (
  6. SlicerApiServerError,
  7. SlicerApiService,
  8. SlicerApiUnavailableError,
  9. SliceResult,
  10. SlicerInputError,
  11. _guess_model_content_type,
  12. )
  13. def _mock_client(handler) -> httpx.AsyncClient:
  14. """Build an httpx.AsyncClient that routes every request through `handler`.
  15. handler signature: (httpx.Request) -> httpx.Response.
  16. """
  17. transport = httpx.MockTransport(handler)
  18. return httpx.AsyncClient(transport=transport, timeout=10.0)
  19. class TestGuessModelContentType:
  20. """The sidecar's multer middleware rejects octet-stream for STL uploads,
  21. so we guess by extension."""
  22. def test_stl(self):
  23. assert _guess_model_content_type("Cube.stl") == "model/stl"
  24. def test_3mf(self):
  25. assert _guess_model_content_type("Bank.3mf") == "model/3mf"
  26. def test_3mf_uppercase(self):
  27. assert _guess_model_content_type("Bank.3MF") == "model/3mf"
  28. def test_step(self):
  29. assert _guess_model_content_type("Cube.step") == "model/step"
  30. def test_stp(self):
  31. assert _guess_model_content_type("Cube.stp") == "model/step"
  32. def test_unknown(self):
  33. assert _guess_model_content_type("foo.bar") == "application/octet-stream"
  34. class TestSliceWithProfiles:
  35. @pytest.mark.asyncio
  36. async def test_happy_path_returns_gcode_and_metadata(self):
  37. captured: dict = {}
  38. def handler(request: httpx.Request) -> httpx.Response:
  39. captured["url"] = str(request.url)
  40. captured["body_len"] = len(request.content)
  41. captured["content_type"] = request.headers.get("content-type", "")
  42. return httpx.Response(
  43. status_code=200,
  44. content=b"; G-CODE START\nG28\n",
  45. headers={
  46. "content-type": "application/octet-stream",
  47. "x-print-time-seconds": "656",
  48. "x-filament-used-g": "0.94",
  49. "x-filament-used-mm": "302.5",
  50. },
  51. )
  52. client = _mock_client(handler)
  53. service = SlicerApiService("http://sidecar:3000", client=client)
  54. result = await service.slice_with_profiles(
  55. model_bytes=b"solid Cube\n",
  56. model_filename="Cube.stl",
  57. printer_profile_json='{"name": "p"}',
  58. process_profile_json='{"name": "pr"}',
  59. filament_profile_jsons=['{"name": "f"}'],
  60. )
  61. assert isinstance(result, SliceResult)
  62. assert result.content == b"; G-CODE START\nG28\n"
  63. assert result.print_time_seconds == 656
  64. assert result.filament_used_g == 0.94
  65. assert result.filament_used_mm == 302.5
  66. assert captured["url"].endswith("/slice")
  67. assert captured["content_type"].startswith("multipart/form-data")
  68. # Roughly: model bytes (>0) + 3 profile JSONs (>0). Sanity check that
  69. # all four parts hit the wire.
  70. assert captured["body_len"] > 0
  71. @pytest.mark.asyncio
  72. async def test_4xx_raises_slicer_input_error(self):
  73. def handler(request: httpx.Request) -> httpx.Response:
  74. return httpx.Response(
  75. status_code=400,
  76. json={"message": "Invalid file type for printerProfile."},
  77. )
  78. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  79. with pytest.raises(SlicerInputError) as exc_info:
  80. await service.slice_with_profiles(
  81. model_bytes=b"x",
  82. model_filename="Cube.stl",
  83. printer_profile_json="{}",
  84. process_profile_json="{}",
  85. filament_profile_jsons=["{}"],
  86. )
  87. assert "Invalid file type" in str(exc_info.value)
  88. @pytest.mark.asyncio
  89. async def test_5xx_raises_server_error(self):
  90. # 5xx from the sidecar = wrapped CLI failed (segfault, range-check
  91. # reject, etc). Distinguished from connection failures so callers
  92. # can retry with a different request shape.
  93. def handler(request: httpx.Request) -> httpx.Response:
  94. return httpx.Response(
  95. status_code=500,
  96. json={"message": "Failed to slice the model"},
  97. )
  98. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  99. with pytest.raises(SlicerApiServerError) as exc_info:
  100. await service.slice_with_profiles(
  101. model_bytes=b"x",
  102. model_filename="Cube.stl",
  103. printer_profile_json="{}",
  104. process_profile_json="{}",
  105. filament_profile_jsons=["{}"],
  106. )
  107. assert "Failed to slice the model" in str(exc_info.value)
  108. @pytest.mark.asyncio
  109. async def test_5xx_includes_sidecar_details_field(self):
  110. """Sidecar's AppError emits ``{message, details}`` — both must end up
  111. in the raised error so ``bambuddy.log`` carries the actual CLI
  112. rejection reason instead of just the generic outer message.
  113. Pinned to fix the regression where every 3MF slice surfaced as
  114. the unhelpful ``Failed to slice the model`` line in production."""
  115. def handler(request: httpx.Request) -> httpx.Response:
  116. return httpx.Response(
  117. status_code=500,
  118. json={
  119. "message": "Failed to slice the model",
  120. "details": "prime_tower_brim_width: -1 not in range [0, 100]",
  121. },
  122. )
  123. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  124. with pytest.raises(SlicerApiServerError) as exc_info:
  125. await service.slice_with_profiles(
  126. model_bytes=b"x",
  127. model_filename="Cube.stl",
  128. printer_profile_json="{}",
  129. process_profile_json="{}",
  130. filament_profile_jsons=["{}"],
  131. )
  132. msg = str(exc_info.value)
  133. assert "Failed to slice the model" in msg
  134. assert "prime_tower_brim_width: -1" in msg
  135. @pytest.mark.asyncio
  136. async def test_5xx_with_only_details_still_surfaces(self):
  137. """If a future sidecar version emits ``details`` without
  138. ``message``, fall back to the details string so we don't end up
  139. with an empty error."""
  140. def handler(request: httpx.Request) -> httpx.Response:
  141. return httpx.Response(
  142. status_code=500,
  143. json={"details": "Slicer killed by SIGSEGV"},
  144. )
  145. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  146. with pytest.raises(SlicerApiServerError) as exc_info:
  147. await service.slice_with_profiles(
  148. model_bytes=b"x",
  149. model_filename="Cube.stl",
  150. printer_profile_json="{}",
  151. process_profile_json="{}",
  152. filament_profile_jsons=["{}"],
  153. )
  154. assert "SIGSEGV" in str(exc_info.value)
  155. @pytest.mark.asyncio
  156. async def test_5xx_with_non_json_body_falls_back_to_text(self):
  157. """Some failure paths (gateway timeouts, bare nginx 502s) return
  158. plain text rather than the JSON envelope. Don't crash trying to
  159. decode it — fall back to the text body."""
  160. def handler(request: httpx.Request) -> httpx.Response:
  161. return httpx.Response(status_code=502, content=b"Bad Gateway")
  162. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  163. with pytest.raises(SlicerApiServerError) as exc_info:
  164. await service.slice_with_profiles(
  165. model_bytes=b"x",
  166. model_filename="Cube.stl",
  167. printer_profile_json="{}",
  168. process_profile_json="{}",
  169. filament_profile_jsons=["{}"],
  170. )
  171. assert "Bad Gateway" in str(exc_info.value)
  172. @pytest.mark.asyncio
  173. async def test_connection_error_raises_unavailable(self):
  174. def handler(request: httpx.Request) -> httpx.Response:
  175. raise httpx.ConnectError("Connection refused")
  176. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  177. with pytest.raises(SlicerApiUnavailableError) as exc_info:
  178. await service.slice_with_profiles(
  179. model_bytes=b"x",
  180. model_filename="Cube.stl",
  181. printer_profile_json="{}",
  182. process_profile_json="{}",
  183. filament_profile_jsons=["{}"],
  184. )
  185. assert "unreachable" in str(exc_info.value).lower()
  186. @pytest.mark.asyncio
  187. async def test_passes_plate_and_export_3mf_options(self):
  188. captured: dict = {}
  189. def handler(request: httpx.Request) -> httpx.Response:
  190. captured["body"] = request.content
  191. return httpx.Response(
  192. status_code=200,
  193. content=b"3MF-BYTES",
  194. headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
  195. )
  196. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  197. await service.slice_with_profiles(
  198. model_bytes=b"x",
  199. model_filename="Cube.stl",
  200. printer_profile_json="{}",
  201. process_profile_json="{}",
  202. filament_profile_jsons=["{}"],
  203. plate=2,
  204. export_3mf=True,
  205. )
  206. body = captured["body"]
  207. # Multipart body should contain the form fields. Quick membership
  208. # check beats parsing the multipart envelope.
  209. assert b'name="plate"' in body
  210. assert b"\r\n2\r\n" in body or b'name="plate"\r\n\r\n2' in body
  211. assert b'name="exportType"' in body
  212. assert b"3mf" in body
  213. @pytest.mark.asyncio
  214. async def test_multi_filament_sends_one_part_per_profile(self):
  215. # Multi-color slicing requires N filament profiles, in plate-slot
  216. # order, sent as N repeated multipart `filamentProfile` parts (NOT a
  217. # single concatenated value). The CLI joins their resulting paths
  218. # with `;` for --load-filaments. A future regression to a dict-shaped
  219. # `files=` would silently keep prior tests green but ship only the
  220. # last filament — pin the wire shape.
  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-BYTES",
  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=['{"a":1}', '{"b":2}', '{"c":3}'],
  236. )
  237. body = captured["body"]
  238. # Three repeated `filamentProfile` parts, in submission order.
  239. assert body.count(b'name="filamentProfile"') == 3
  240. assert b'{"a":1}' in body and b'{"b":2}' in body and b'{"c":3}' in body
  241. # Parts present in plate order — the 'a' bytes appear before 'b'
  242. # which appear before 'c'. (httpx preserves the list order.)
  243. assert body.index(b'{"a":1}') < body.index(b'{"b":2}') < body.index(b'{"c":3}')
  244. @pytest.mark.asyncio
  245. async def test_missing_metadata_headers_default_to_zero(self):
  246. # The /slice endpoint always sets these on success, but be defensive
  247. # so a stripped reverse-proxy or older sidecar doesn't crash callers.
  248. def handler(request: httpx.Request) -> httpx.Response:
  249. return httpx.Response(status_code=200, content=b"; gcode")
  250. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  251. result = await service.slice_with_profiles(
  252. model_bytes=b"x",
  253. model_filename="Cube.stl",
  254. printer_profile_json="{}",
  255. process_profile_json="{}",
  256. filament_profile_jsons=["{}"],
  257. )
  258. assert result.print_time_seconds == 0
  259. assert result.filament_used_g == 0.0
  260. assert result.filament_used_mm == 0.0
  261. class TestHealth:
  262. @pytest.mark.asyncio
  263. async def test_health_returns_body(self):
  264. def handler(request: httpx.Request) -> httpx.Response:
  265. return httpx.Response(
  266. status_code=200,
  267. json={"status": "healthy", "checks": {"orcaslicer": {"available": True}}},
  268. )
  269. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  270. body = await service.health()
  271. assert body["status"] == "healthy"
  272. @pytest.mark.asyncio
  273. async def test_health_unreachable_raises(self):
  274. def handler(request: httpx.Request) -> httpx.Response:
  275. raise httpx.ConnectError("no route")
  276. service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
  277. with pytest.raises(SlicerApiUnavailableError):
  278. await service.health()