| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810 |
- """Tests for SlicerApiService."""
- from __future__ import annotations
- import asyncio
- import httpx
- import pytest
- from backend.app.services.slicer_api import (
- BundleNotFoundError,
- BundleSummary,
- SlicerApiServerError,
- SlicerApiService,
- SlicerApiUnavailableError,
- SliceResult,
- SlicerInputError,
- _guess_model_content_type,
- )
- def _mock_client(handler) -> httpx.AsyncClient:
- """Build an httpx.AsyncClient that routes every request through `handler`.
- handler signature: (httpx.Request) -> httpx.Response.
- """
- transport = httpx.MockTransport(handler)
- return httpx.AsyncClient(transport=transport, timeout=10.0)
- class TestGuessModelContentType:
- """The sidecar's multer middleware rejects octet-stream for STL uploads,
- so we guess by extension."""
- def test_stl(self):
- assert _guess_model_content_type("Cube.stl") == "model/stl"
- def test_3mf(self):
- assert _guess_model_content_type("Bank.3mf") == "model/3mf"
- def test_3mf_uppercase(self):
- assert _guess_model_content_type("Bank.3MF") == "model/3mf"
- def test_step(self):
- assert _guess_model_content_type("Cube.step") == "model/step"
- def test_stp(self):
- assert _guess_model_content_type("Cube.stp") == "model/step"
- def test_unknown(self):
- assert _guess_model_content_type("foo.bar") == "application/octet-stream"
- class TestSliceWithProfiles:
- @pytest.mark.asyncio
- async def test_happy_path_returns_gcode_and_metadata(self):
- captured: dict = {}
- def handler(request: httpx.Request) -> httpx.Response:
- captured["url"] = str(request.url)
- captured["body_len"] = len(request.content)
- captured["content_type"] = request.headers.get("content-type", "")
- return httpx.Response(
- status_code=200,
- content=b"; G-CODE START\nG28\n",
- headers={
- "content-type": "application/octet-stream",
- "x-print-time-seconds": "656",
- "x-filament-used-g": "0.94",
- "x-filament-used-mm": "302.5",
- },
- )
- client = _mock_client(handler)
- service = SlicerApiService("http://sidecar:3000", client=client)
- result = await service.slice_with_profiles(
- model_bytes=b"solid Cube\n",
- model_filename="Cube.stl",
- printer_profile_json='{"name": "p"}',
- process_profile_json='{"name": "pr"}',
- filament_profile_jsons=['{"name": "f"}'],
- )
- assert isinstance(result, SliceResult)
- assert result.content == b"; G-CODE START\nG28\n"
- assert result.print_time_seconds == 656
- assert result.filament_used_g == 0.94
- assert result.filament_used_mm == 302.5
- assert captured["url"].endswith("/slice")
- assert captured["content_type"].startswith("multipart/form-data")
- # Roughly: model bytes (>0) + 3 profile JSONs (>0). Sanity check that
- # all four parts hit the wire.
- assert captured["body_len"] > 0
- @pytest.mark.asyncio
- async def test_4xx_raises_slicer_input_error(self):
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- status_code=400,
- json={"message": "Invalid file type for printerProfile."},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerInputError) as exc_info:
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- )
- assert "Invalid file type" in str(exc_info.value)
- @pytest.mark.asyncio
- async def test_5xx_raises_server_error(self):
- # 5xx from the sidecar = wrapped CLI failed (segfault, range-check
- # reject, etc). Distinguished from connection failures so callers
- # can retry with a different request shape.
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- status_code=500,
- json={"message": "Failed to slice the model"},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiServerError) as exc_info:
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- )
- assert "Failed to slice the model" in str(exc_info.value)
- @pytest.mark.asyncio
- async def test_5xx_includes_sidecar_details_field(self):
- """Sidecar's AppError emits ``{message, details}`` — both must end up
- in the raised error so ``bambuddy.log`` carries the actual CLI
- rejection reason instead of just the generic outer message.
- Pinned to fix the regression where every 3MF slice surfaced as
- the unhelpful ``Failed to slice the model`` line in production."""
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- status_code=500,
- json={
- "message": "Failed to slice the model",
- "details": "prime_tower_brim_width: -1 not in range [0, 100]",
- },
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiServerError) as exc_info:
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- )
- msg = str(exc_info.value)
- assert "Failed to slice the model" in msg
- assert "prime_tower_brim_width: -1" in msg
- @pytest.mark.asyncio
- async def test_5xx_with_only_details_still_surfaces(self):
- """If a future sidecar version emits ``details`` without
- ``message``, fall back to the details string so we don't end up
- with an empty error."""
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- status_code=500,
- json={"details": "Slicer killed by SIGSEGV"},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiServerError) as exc_info:
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- )
- assert "SIGSEGV" in str(exc_info.value)
- @pytest.mark.asyncio
- async def test_5xx_with_non_json_body_falls_back_to_text(self):
- """Some failure paths (gateway timeouts, bare nginx 502s) return
- plain text rather than the JSON envelope. Don't crash trying to
- decode it — fall back to the text body."""
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(status_code=502, content=b"Bad Gateway")
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiServerError) as exc_info:
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- )
- assert "Bad Gateway" in str(exc_info.value)
- @pytest.mark.asyncio
- async def test_connection_error_raises_unavailable(self):
- def handler(request: httpx.Request) -> httpx.Response:
- raise httpx.ConnectError("Connection refused")
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiUnavailableError) as exc_info:
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- )
- assert "unreachable" in str(exc_info.value).lower()
- @pytest.mark.asyncio
- async def test_passes_plate_and_export_3mf_options(self):
- captured: dict = {}
- def handler(request: httpx.Request) -> httpx.Response:
- captured["body"] = request.content
- return httpx.Response(
- status_code=200,
- content=b"3MF-BYTES",
- headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- plate=2,
- export_3mf=True,
- )
- body = captured["body"]
- # Multipart body should contain the form fields. Quick membership
- # check beats parsing the multipart envelope.
- assert b'name="plate"' in body
- assert b"\r\n2\r\n" in body or b'name="plate"\r\n\r\n2' in body
- assert b'name="exportType"' in body
- assert b"3mf" in body
- @pytest.mark.asyncio
- async def test_arrange_true_emits_form_field(self):
- """#1493: cross-class re-slices set arrange=True so BambuStudio
- repositions objects for the target bed. The flag must arrive as
- a multipart form field the sidecar's SlicingSettings parses."""
- captured: dict = {}
- def handler(request: httpx.Request) -> httpx.Response:
- captured["body"] = request.content
- return httpx.Response(
- status_code=200,
- content=b"3MF",
- headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.3mf",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- arrange=True,
- )
- body = captured["body"]
- assert b'name="arrange"' in body
- # Sidecar treats non-empty strings as truthy, so "true" suffices.
- assert b"true" in body
- @pytest.mark.asyncio
- async def test_arrange_false_omits_form_field(self):
- """Default arrange=False keeps the wire payload identical to the
- pre-#1493 shape — no spurious form field that downstream sidecar
- versions might mis-parse."""
- captured: dict = {}
- def handler(request: httpx.Request) -> httpx.Response:
- captured["body"] = request.content
- return httpx.Response(
- status_code=200,
- content=b"3MF",
- headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.3mf",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- )
- assert b'name="arrange"' not in captured["body"]
- @pytest.mark.asyncio
- async def test_multi_filament_sends_one_part_per_profile(self):
- # Multi-color slicing requires N filament profiles, in plate-slot
- # order, sent as N repeated multipart `filamentProfile` parts (NOT a
- # single concatenated value). The CLI joins their resulting paths
- # with `;` for --load-filaments. A future regression to a dict-shaped
- # `files=` would silently keep prior tests green but ship only the
- # last filament — pin the wire shape.
- captured: dict = {}
- def handler(request: httpx.Request) -> httpx.Response:
- captured["body"] = request.content
- return httpx.Response(
- status_code=200,
- content=b"3MF-BYTES",
- headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.3mf",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=['{"a":1}', '{"b":2}', '{"c":3}'],
- )
- body = captured["body"]
- # Three repeated `filamentProfile` parts, in submission order.
- assert body.count(b'name="filamentProfile"') == 3
- assert b'{"a":1}' in body and b'{"b":2}' in body and b'{"c":3}' in body
- # Parts present in plate order — the 'a' bytes appear before 'b'
- # which appear before 'c'. (httpx preserves the list order.)
- assert body.index(b'{"a":1}') < body.index(b'{"b":2}') < body.index(b'{"c":3}')
- @pytest.mark.asyncio
- async def test_missing_metadata_headers_default_to_zero(self):
- # The /slice endpoint always sets these on success, but be defensive
- # so a stripped reverse-proxy or older sidecar doesn't crash callers.
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(status_code=200, content=b"; gcode")
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- result = await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- )
- assert result.print_time_seconds == 0
- assert result.filament_used_g == 0.0
- assert result.filament_used_mm == 0.0
- class TestHealth:
- @pytest.mark.asyncio
- async def test_health_returns_body(self):
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- status_code=200,
- json={"status": "healthy", "checks": {"orcaslicer": {"available": True}}},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- body = await service.health()
- assert body["status"] == "healthy"
- @pytest.mark.asyncio
- async def test_health_unreachable_raises(self):
- def handler(request: httpx.Request) -> httpx.Response:
- raise httpx.ConnectError("no route")
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiUnavailableError):
- await service.health()
- class TestSliceWithProfilesProgress:
- """Live-progress wiring for slice_with_profiles.
- When the caller supplies a ``request_id`` and an ``on_progress``
- callback, the service forwards the id as a ``requestId`` form field
- (the sidecar uses it to wire up `--pipe` per request) and spawns a
- background poller that calls back into ``on_progress`` for each
- snapshot the sidecar publishes. The poller is cancelled the moment
- the slice POST returns.
- """
- @pytest.mark.asyncio
- async def test_request_id_forwarded_as_form_field(self):
- captured: dict = {}
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path == "/slice":
- captured["body"] = request.content
- return httpx.Response(
- status_code=200,
- content=b"PK\x03\x04 fake",
- headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
- )
- # /slice/progress/<id> — return 404 so the poller exits cleanly.
- return httpx.Response(status_code=404, json={"error": "not_found"})
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- request_id="abc-123",
- on_progress=lambda _snap: None,
- )
- # The form field name on the wire is `requestId` (camelCase) to
- # match the sidecar's SlicingSettings shape.
- body = captured["body"].decode("utf-8", errors="ignore")
- assert "requestId" in body
- assert "abc-123" in body
- @pytest.mark.asyncio
- async def test_on_progress_called_with_snapshots(self):
- # Drive enough poller ticks for at least one progress 200 to land
- # before the slice response unblocks the caller.
- slice_release = asyncio.Event()
- snapshots: list[dict] = []
- async def slice_handler() -> httpx.Response:
- # Hold the slice POST until the test signals release, mimicking
- # a real long-running slice.
- await slice_release.wait()
- return httpx.Response(
- status_code=200,
- content=b"PK\x03\x04",
- headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
- )
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path == "/slice":
- # MockTransport supports async handlers if we return a
- # coroutine — but the simpler path is to drive completion
- # via the captured event below.
- pass
- if request.url.path == "/slice/progress/req-1":
- return httpx.Response(
- status_code=200,
- json={
- "stage": "Generating G-code",
- "total_percent": 75,
- "plate_percent": 80,
- "plate_index": 1,
- "plate_count": 1,
- "updated_at": 0,
- },
- )
- return httpx.Response(404)
- # Use an async handler so the slice POST blocks until released.
- async def async_handler(request: httpx.Request) -> httpx.Response:
- if request.url.path == "/slice":
- return await slice_handler()
- return handler(request)
- client = httpx.AsyncClient(transport=httpx.MockTransport(async_handler))
- service = SlicerApiService("http://sidecar:3000", client=client)
- # Run the slice with progress callback, releasing it after a beat.
- async def release_after_first_snapshot():
- # Wait until the poller has published at least one snapshot
- # via the on_progress callback, then unblock the slice POST.
- for _ in range(60):
- if snapshots:
- break
- await asyncio.sleep(0.05)
- slice_release.set()
- release_task = asyncio.create_task(release_after_first_snapshot())
- try:
- await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- request_id="req-1",
- on_progress=lambda snap: snapshots.append(snap),
- )
- finally:
- release_task.cancel()
- await asyncio.gather(release_task, return_exceptions=True)
- await client.aclose()
- assert snapshots, "on_progress was never called"
- first = snapshots[0]
- assert first["stage"] == "Generating G-code"
- assert first["total_percent"] == 75
- @pytest.mark.asyncio
- async def test_progress_404_does_not_crash_or_stop_polling(self):
- """A 404 from /slice/progress/:id is expected during the early
- race window (POST fired before sidecar's progressStore.start()
- ran) and from older sidecars without progress support. Neither
- should crash the slice or block the response — the poller just
- keeps trying until the outer cancel fires."""
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path == "/slice":
- return httpx.Response(
- status_code=200,
- content=b"PK\x03\x04",
- headers={"x-print-time-seconds": "1", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
- )
- return httpx.Response(status_code=404, json={"error": "not_found"})
- snapshots: list[dict] = []
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- result = await service.slice_with_profiles(
- model_bytes=b"x",
- model_filename="Cube.stl",
- printer_profile_json="{}",
- process_profile_json="{}",
- filament_profile_jsons=["{}"],
- request_id="legacy-sidecar",
- on_progress=lambda snap: snapshots.append(snap),
- )
- assert result is not None
- # Sustained 404 → no snapshots ever forwarded.
- assert snapshots == []
- # ── BundleSummary parsing + bundle CRUD client methods ─────────────────────
- class TestBundleClientMethods:
- """Coverage for import_bundle / list_bundles / get_bundle / delete_bundle.
- Mirrors the existing SlicerApiService tests' mock-transport pattern. The
- bundle endpoints are simple JSON CRUD on the sidecar, but the response
- parsing has to remain forgiving (newer sidecars may add fields, older
- ones may omit some) and the failure modes have to map cleanly to our
- typed exceptions so route handlers can pick the right HTTP status.
- """
- SAMPLE_SUMMARY = {
- "id": "2bd8722dd20a837e",
- "printer_preset_name": "# Bambu Lab H2D 0.4 nozzle",
- "printer": ["# Bambu Lab H2D 0.4 nozzle"],
- "process": ["# 0.20mm Standard @BBL H2D"],
- "filament": ["# Bambu PLA Basic @BBL H2D"],
- "version": "02.06.00.50",
- }
- @pytest.mark.asyncio
- async def test_import_bundle_happy_path(self):
- captured: dict = {}
- def handler(request: httpx.Request) -> httpx.Response:
- captured["url"] = str(request.url)
- captured["method"] = request.method
- captured["content_type"] = request.headers.get("content-type", "")
- return httpx.Response(status_code=201, json=self.SAMPLE_SUMMARY)
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- summary = await service.import_bundle(b"PK\x03\x04zip-bytes", filename="H2D.bbscfg")
- assert isinstance(summary, BundleSummary)
- assert summary.id == "2bd8722dd20a837e"
- assert summary.printer == ["# Bambu Lab H2D 0.4 nozzle"]
- assert summary.process == ["# 0.20mm Standard @BBL H2D"]
- assert summary.filament == ["# Bambu PLA Basic @BBL H2D"]
- assert captured["method"] == "POST"
- assert captured["url"].endswith("/profiles/bundle")
- assert captured["content_type"].startswith("multipart/form-data")
- @pytest.mark.asyncio
- async def test_import_bundle_400_raises_input_error(self):
- # Non-.bbscfg uploads, corrupt zips, malicious entry names — all
- # rejected by the sidecar with 4xx so the user can fix and retry.
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- status_code=400,
- json={"message": "Bundle is missing bundle_structure.json"},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerInputError) as exc_info:
- await service.import_bundle(b"not a zip")
- assert "missing bundle_structure" in str(exc_info.value)
- @pytest.mark.asyncio
- async def test_import_bundle_5xx_raises_server_error(self):
- # Disk-write failure on DATA_PATH — rare but observable when /data
- # is a tmpfs that filled up.
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(status_code=500, json={"message": "ENOSPC"})
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiServerError):
- await service.import_bundle(b"x")
- @pytest.mark.asyncio
- async def test_import_bundle_connection_error(self):
- def handler(request: httpx.Request) -> httpx.Response:
- raise httpx.ConnectError("connection refused")
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiUnavailableError):
- await service.import_bundle(b"x")
- @pytest.mark.asyncio
- async def test_list_bundles_returns_summaries(self):
- def handler(request: httpx.Request) -> httpx.Response:
- assert request.url.path == "/profiles/bundles"
- return httpx.Response(status_code=200, json=[self.SAMPLE_SUMMARY])
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- bundles = await service.list_bundles()
- assert len(bundles) == 1
- assert bundles[0].id == self.SAMPLE_SUMMARY["id"]
- @pytest.mark.asyncio
- async def test_list_bundles_empty_array(self):
- # Sidecar returns [] when no bundles imported yet — must not raise.
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(status_code=200, json=[])
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- assert await service.list_bundles() == []
- @pytest.mark.asyncio
- async def test_list_bundles_non_array_raises(self):
- # Older / mis-configured sidecar returning {} instead of []. Surface
- # the bug with a clear server error rather than silently treating
- # malformed payload as empty.
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(status_code=200, json={"unexpected": "shape"})
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiServerError):
- await service.list_bundles()
- @pytest.mark.asyncio
- async def test_get_bundle_404_raises_not_found(self):
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(status_code=404, json={"message": "not found"})
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(BundleNotFoundError):
- await service.get_bundle("deadbeef00000000")
- @pytest.mark.asyncio
- async def test_get_bundle_happy_path(self):
- def handler(request: httpx.Request) -> httpx.Response:
- assert request.url.path == "/profiles/bundles/2bd8722dd20a837e"
- return httpx.Response(status_code=200, json=self.SAMPLE_SUMMARY)
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- summary = await service.get_bundle("2bd8722dd20a837e")
- assert summary.id == "2bd8722dd20a837e"
- @pytest.mark.asyncio
- async def test_delete_bundle_204_succeeds_silently(self):
- def handler(request: httpx.Request) -> httpx.Response:
- assert request.method == "DELETE"
- return httpx.Response(status_code=204)
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- # Should not raise.
- await service.delete_bundle("2bd8722dd20a837e")
- @pytest.mark.asyncio
- async def test_delete_bundle_404_raises_not_found(self):
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(status_code=404, json={"message": "not found"})
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(BundleNotFoundError):
- await service.delete_bundle("missing")
- class TestSliceWithBundle:
- """The bundle slice path takes the same model upload but replaces the
- profile-attachment fields with bundle-id + preset-name form fields.
- Coverage for the form shape, the multi-filament join, and the same
- 4xx/5xx mapping as slice_with_profiles."""
- @pytest.mark.asyncio
- async def test_form_fields_and_filament_join(self):
- captured: dict = {}
- def handler(request: httpx.Request) -> httpx.Response:
- captured["url"] = str(request.url)
- captured["body"] = request.content
- captured["content_type"] = request.headers.get("content-type", "")
- return httpx.Response(
- status_code=200,
- content=b"; G-CODE",
- headers={
- "x-print-time-seconds": "60",
- "x-filament-used-g": "1.0",
- "x-filament-used-mm": "100.0",
- },
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- result = await service.slice_with_bundle(
- model_bytes=b"solid Cube\n",
- model_filename="Cube.stl",
- bundle_id="2bd8722dd20a837e",
- printer_name="# Bambu Lab H2D 0.4 nozzle",
- process_name="# 0.20mm Standard @BBL H2D",
- filament_names=["# Bambu PLA Basic @BBL H2D", "# Bambu PETG HF @BBL H2D"],
- )
- assert isinstance(result, SliceResult)
- assert result.print_time_seconds == 60
- assert captured["url"].endswith("/slice")
- assert captured["content_type"].startswith("multipart/form-data")
- # Multi-filament joined with ';' — the sidecar's parser splits on
- # both ';' and ',' so the wire format is the more-explicit ';'.
- body = captured["body"]
- assert b"# Bambu PLA Basic @BBL H2D;# Bambu PETG HF @BBL H2D" in body
- # Each form field appears in the multipart body.
- assert b'name="bundle"' in body
- assert b'name="printerName"' in body
- assert b'name="processName"' in body
- assert b'name="filamentNames"' in body
- # Bundle id round-trips on the wire.
- assert b"2bd8722dd20a837e" in body
- @pytest.mark.asyncio
- async def test_arrange_true_emits_form_field(self):
- """#1493: bundle dispatch also forwards arrange=True so cross-class
- slices via .bbscfg bundles get the same BS auto-arrange behaviour
- as the preset path."""
- captured: dict = {}
- def handler(request: httpx.Request) -> httpx.Response:
- captured["body"] = request.content
- return httpx.Response(
- status_code=200,
- content=b"3MF",
- headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- await service.slice_with_bundle(
- model_bytes=b"x",
- model_filename="Cube.3mf",
- bundle_id="abc",
- printer_name="p",
- process_name="pr",
- filament_names=["f"],
- arrange=True,
- )
- assert b'name="arrange"' in captured["body"]
- @pytest.mark.asyncio
- async def test_404_unknown_preset_maps_to_input_error(self):
- # Sidecar returns 404 when bundle exists but preset name doesn't.
- # The slice route classifies this as user-correctable input error,
- # not server failure.
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- status_code=404,
- json={"message": 'process preset "Imaginary" not found in bundle "abc"'},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerInputError):
- await service.slice_with_bundle(
- model_bytes=b"x",
- model_filename="Cube.stl",
- bundle_id="abc",
- printer_name="p",
- process_name="Imaginary",
- filament_names=["f"],
- )
- @pytest.mark.asyncio
- async def test_5xx_maps_to_server_error(self):
- # CLI segfault on the resolved triplet — same handling as slice_with_profiles.
- def handler(request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- status_code=500,
- json={"message": "Slicer process failed (signal SIGSEGV)"},
- )
- service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
- with pytest.raises(SlicerApiServerError):
- await service.slice_with_bundle(
- model_bytes=b"x",
- model_filename="Cube.3mf",
- bundle_id="abc",
- printer_name="p",
- process_name="pr",
- filament_names=["f"],
- )
|