diff --git a/backend/app/api/routes/slicer_presets.py b/backend/app/api/routes/slicer_presets.py index a48b871b5..ee166e39e 100644 --- a/backend/app/api/routes/slicer_presets.py +++ b/backend/app/api/routes/slicer_presets.py @@ -16,7 +16,7 @@ import json import logging import time -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -37,7 +37,14 @@ from backend.app.services.bambu_cloud import ( BambuCloudError, BambuCloudService, ) -from backend.app.services.slicer_api import SlicerApiError, SlicerApiService +from backend.app.services.slicer_api import ( + BundleNotFoundError, + BundleSummary, + SlicerApiError, + SlicerApiService, + SlicerApiUnavailableError, + SlicerInputError, +) logger = logging.getLogger(__name__) @@ -368,6 +375,145 @@ async def list_unified_presets( ) +def _bundle_summary_to_dict(b: BundleSummary) -> dict: + """Serialize a BundleSummary for the JSON response. The frontend uses + these arrays to populate the preset dropdowns when a user picks the + bundle as the slice source. + """ + return { + "id": b.id, + "printer_preset_name": b.printer_preset_name, + "printer": b.printer, + "process": b.process, + "filament": b.filament, + "version": b.version, + } + + +@router.post("/bundles", status_code=201) +async def import_slicer_bundle( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD), +): + """Forward a BambuStudio Printer Preset Bundle (.bbscfg) to the sidecar. + + The user exports their printer's preset bundle from BambuStudio (File + -> Export -> Export Preset Bundle, "Printer preset bundle" option). + Uploading it here unpacks the bundle on the sidecar and exposes its + inner printer / process / filament presets to subsequent slice + requests via the bundle-id selector. + + Idempotent: re-uploading the same file yields the same id (sidecar + hashes the zip content), so duplicate uploads collapse rather than + accumulate. + """ + api_url = await _resolve_slicer_api_url(db) + if not api_url: + raise HTTPException(status_code=503, detail="No slicer sidecar configured") + + # Multer on the sidecar caps bundle uploads at 50MB. We don't enforce + # that here — let the sidecar's filter own the limit so it stays in + # one place — but we do reject empty / huge files at the FastAPI + # layer to avoid pointlessly streaming them to the sidecar first. + contents = await file.read() + if not contents: + raise HTTPException(status_code=400, detail="Bundle file is empty") + filename = file.filename or "bundle.bbscfg" + + try: + async with SlicerApiService(base_url=api_url) as svc: + summary = await svc.import_bundle(contents, filename=filename) + except SlicerInputError as e: + # Sidecar's 4xx — most likely a non-.bbscfg upload, a corrupt zip, + # or a path-traversal entry that the manifest validator caught. + # Surface verbatim so the user sees the actual reason in the toast. + raise HTTPException(status_code=400, detail=str(e)) from e + except SlicerApiUnavailableError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except SlicerApiError as e: + # 5xx from the sidecar's import path is rare — usually a disk + # write failure inside DATA_PATH/bundles. 502 (bad gateway) is + # closer to the truth than 500 here, since we're proxying. + raise HTTPException(status_code=502, detail=str(e)) from e + return _bundle_summary_to_dict(summary) + + +@router.get("/bundles") +async def list_slicer_bundles( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD), +): + """List every Printer Preset Bundle currently stored on the sidecar. + + Drives the SliceModal's "Bundle" tier and a Settings panel where + users can review / delete imported bundles. Returns ``[]`` when the + sidecar has no bundles imported yet. + """ + api_url = await _resolve_slicer_api_url(db) + if not api_url: + # No sidecar configured: empty list rather than 503 so the modal + # renders cleanly. Same shape as the bundled-presets fallback. + return [] + try: + async with SlicerApiService(base_url=api_url) as svc: + bundles = await svc.list_bundles() + except SlicerApiUnavailableError as e: + # Sidecar offline: surface as 503 so the frontend can show a + # banner. Differs from the bundled-tier behaviour because that + # path also has cloud + local fallbacks; bundles is the only + # source for its tier. + raise HTTPException(status_code=503, detail=str(e)) from e + except SlicerApiError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + return [_bundle_summary_to_dict(b) for b in bundles] + + +@router.get("/bundles/{bundle_id}") +async def get_slicer_bundle( + bundle_id: str, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD), +): + """Return one bundle by id. 404 if it doesn't exist on the sidecar.""" + api_url = await _resolve_slicer_api_url(db) + if not api_url: + raise HTTPException(status_code=503, detail="No slicer sidecar configured") + try: + async with SlicerApiService(base_url=api_url) as svc: + summary = await svc.get_bundle(bundle_id) + except BundleNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except SlicerApiUnavailableError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except SlicerApiError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + return _bundle_summary_to_dict(summary) + + +@router.delete("/bundles/{bundle_id}", status_code=204) +async def delete_slicer_bundle( + bundle_id: str, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD), +): + """Remove a stored bundle from the sidecar. Future slice requests + referencing this id will fail with 404 from the sidecar. + """ + api_url = await _resolve_slicer_api_url(db) + if not api_url: + raise HTTPException(status_code=503, detail="No slicer sidecar configured") + try: + async with SlicerApiService(base_url=api_url) as svc: + await svc.delete_bundle(bundle_id) + except BundleNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except SlicerApiUnavailableError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except SlicerApiError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + + @router.get("/preview-progress/{request_id}") async def get_preview_slice_progress( request_id: str, @@ -392,8 +538,6 @@ async def get_preview_slice_progress( api_url = await _resolve_slicer_api_url(db) if not api_url: - from fastapi import HTTPException - raise HTTPException(status_code=503, detail="No slicer sidecar configured") url = f"{api_url}/slice/progress/{request_id}" try: @@ -402,11 +546,7 @@ async def get_preview_slice_progress( except httpx.RequestError: # Sidecar unreachable: surface as 503 instead of 500 so the # frontend's poller can keep trying without flagging a hard error. - from fastapi import HTTPException - raise HTTPException(status_code=503, detail="Slicer sidecar unreachable") from None if response.status_code == 404: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="Progress unavailable") return response.json() diff --git a/backend/app/services/slicer_api.py b/backend/app/services/slicer_api.py index df6a482e5..ee239e831 100644 --- a/backend/app/services/slicer_api.py +++ b/backend/app/services/slicer_api.py @@ -47,6 +47,42 @@ class SliceResult(NamedTuple): filament_used_mm: float +class BundleSummary(NamedTuple): + """Sidecar's view of a stored Printer Preset Bundle (.bbscfg). + + Mirrors the JSON shape returned by `/profiles/bundle(s)` on the + sidecar — `printer`, `process`, `filament` are each a list of preset + names available within the bundle (without the `.json` extension and + without the BambuStudio "# " user-clone prefix; the sidecar accepts + both forms when looking them up at slice time). + """ + + id: str + printer_preset_name: str + printer: list[str] + process: list[str] + filament: list[str] + version: str | None + + +class BundleNotFoundError(SlicerApiError): + """Sidecar returned 404 for the bundle id (deleted, never imported).""" + + +def _parse_bundle_summary(payload: dict) -> BundleSummary: + """Build a BundleSummary from the sidecar's JSON. Tolerant of missing + optional fields so a sidecar that adds keys later doesn't break parsing. + """ + return BundleSummary( + id=str(payload.get("id") or ""), + printer_preset_name=str(payload.get("printer_preset_name") or ""), + printer=list(payload.get("printer") or []), + process=list(payload.get("process") or []), + filament=list(payload.get("filament") or []), + version=payload.get("version"), + ) + + _shared_http_client: httpx.AsyncClient | None = None @@ -155,6 +191,102 @@ class SlicerApiService: raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}") return response.json() + async def import_bundle( + self, + zip_bytes: bytes, + *, + filename: str = "bundle.bbscfg", + ) -> BundleSummary: + """POST /profiles/bundle — upload a BambuStudio Printer Preset Bundle. + + Idempotent on the sidecar side: re-uploading the same file yields the + same id (deterministic SHA-256 prefix of the zip content) and the + sidecar reuses its existing extracted directory, so re-importing is + always safe. + + Raises: + SlicerInputError: 4xx — bundle isn't a valid .bbscfg, or fails the + sidecar's path-traversal / manifest validation. + SlicerApiUnavailableError: connection error or 5xx. + """ + files = {"file": (filename, zip_bytes, "application/zip")} + try: + response = await self._client.post( + f"{self.base_url}/profiles/bundle", + files=files, + timeout=60.0, + ) + except httpx.RequestError as exc: + raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc + if response.status_code >= 500: + raise SlicerApiServerError( + f"Slicer sidecar /profiles/bundle failed ({response.status_code}): {_format_sidecar_error(response)}", + ) + if response.status_code >= 400: + raise SlicerInputError( + f"Slicer sidecar rejected bundle ({response.status_code}): {_format_sidecar_error(response)}", + ) + return _parse_bundle_summary(response.json()) + + async def list_bundles(self) -> list[BundleSummary]: + """GET /profiles/bundles — list every imported bundle and its presets. + + Returns an empty list when the sidecar's bundle store is empty (the + sidecar returns ``[]`` rather than 404 in that case). Network errors + and 5xx surface as ``SlicerApiUnavailableError`` so callers can + decide whether to render an empty UI or a "sidecar offline" banner. + """ + try: + response = await self._client.get(f"{self.base_url}/profiles/bundles", timeout=10.0) + except httpx.RequestError as exc: + raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc + if response.status_code >= 400: + raise SlicerApiUnavailableError( + f"Slicer sidecar /profiles/bundles returned {response.status_code}", + ) + payload = response.json() + if not isinstance(payload, list): + raise SlicerApiServerError("Slicer sidecar returned non-array bundle list") + return [_parse_bundle_summary(b) for b in payload if isinstance(b, dict)] + + async def get_bundle(self, bundle_id: str) -> BundleSummary: + """GET /profiles/bundles/ — single bundle summary. + + Raises: + BundleNotFoundError: 404 — id does not exist on the sidecar. + SlicerApiUnavailableError: connection error or 5xx. + """ + try: + response = await self._client.get( + f"{self.base_url}/profiles/bundles/{bundle_id}", + timeout=10.0, + ) + except httpx.RequestError as exc: + raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc + if response.status_code == 404: + raise BundleNotFoundError(f"Bundle {bundle_id!r} not found on sidecar") + if response.status_code >= 400: + raise SlicerApiUnavailableError( + f"Slicer sidecar /profiles/bundles/{bundle_id} returned {response.status_code}", + ) + return _parse_bundle_summary(response.json()) + + async def delete_bundle(self, bundle_id: str) -> None: + """DELETE /profiles/bundles/ — remove a stored bundle.""" + try: + response = await self._client.delete( + f"{self.base_url}/profiles/bundles/{bundle_id}", + timeout=10.0, + ) + except httpx.RequestError as exc: + raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc + if response.status_code == 404: + raise BundleNotFoundError(f"Bundle {bundle_id!r} not found on sidecar") + if response.status_code >= 400: + raise SlicerApiUnavailableError( + f"Slicer sidecar DELETE /profiles/bundles/{bundle_id} returned {response.status_code}", + ) + async def _poll_progress( self, request_id: str, @@ -292,6 +424,97 @@ class SlicerApiService: filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")), ) + async def slice_with_bundle( + self, + *, + model_bytes: bytes, + model_filename: str, + bundle_id: str, + printer_name: str, + process_name: str, + filament_names: list[str], + plate: int | None = None, + export_3mf: bool = False, + request_id: str | None = None, + on_progress: Callable[[dict], None] | None = None, + ) -> SliceResult: + """POST /slice with bundle id + per-category preset names. + + Asks the sidecar to materialize the printer / process / filament + JSONs from a previously-imported `.bbscfg`, instead of accepting + them as multipart attachments. Equivalent to + ``slice_with_profiles`` from the user's perspective — same return + shape, same 4xx/5xx semantics, same progress-poll wiring — but + the sidecar saves the round-trip of re-uploading the JSONs every + time a user kicks off a slice with the same bundle. + + ``filament_names`` is plate-slot-ordered: index 0 is slot 1, etc. + Single-color callers pass a one-element list. The sidecar joins + them as semicolon-separated `--load-filaments` for the CLI. + + Raises: + SlicerInputError: 4xx — bundle / preset name not found, etc. + SlicerApiServerError: sidecar 5xx (CLI failure on resolved + triplet — same conditions that fail slice_with_profiles). + SlicerApiUnavailableError: connection error. + """ + files = { + "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)), + } + data: dict[str, str | list[str]] = { + "bundle": bundle_id, + "printerName": printer_name, + "processName": process_name, + } + # The sidecar's SlicingSettings supports both `filamentName` (single + # legacy field, kept for clients that pre-date multi-color) and + # `filamentNames` (semicolon/comma-separated, matches multi-color + # uploads). Always send the array form so a single-slot case still + # ends up in the same code path on the sidecar. + data["filamentNames"] = ";".join(filament_names) + if plate is not None: + data["plate"] = str(plate) + if export_3mf: + data["exportType"] = "3mf" + if request_id is not None: + data["requestId"] = request_id + + progress_task: asyncio.Task | None = None + if request_id is not None and on_progress is not None: + progress_task = asyncio.create_task( + self._poll_progress(request_id, on_progress), + name=f"slicer-progress-{request_id}", + ) + + try: + response = await self._client.post( + f"{self.base_url}/slice", + files=files, + data=data, + timeout=self.timeout_seconds, + ) + except httpx.RequestError as exc: + raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc + finally: + if progress_task is not None: + progress_task.cancel() + try: + await progress_task + except (asyncio.CancelledError, Exception): + pass + + if response.status_code >= 500: + raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}") + if response.status_code >= 400: + raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}") + + return SliceResult( + content=response.content, + print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")), + filament_used_g=_safe_float(response.headers.get("x-filament-used-g")), + filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")), + ) + async def slice_without_profiles( self, *, diff --git a/backend/tests/unit/services/test_slicer_api.py b/backend/tests/unit/services/test_slicer_api.py index 8f4dc7517..a904c7337 100644 --- a/backend/tests/unit/services/test_slicer_api.py +++ b/backend/tests/unit/services/test_slicer_api.py @@ -8,6 +8,8 @@ import httpx import pytest from backend.app.services.slicer_api import ( + BundleNotFoundError, + BundleSummary, SlicerApiServerError, SlicerApiService, SlicerApiUnavailableError, @@ -479,3 +481,246 @@ class TestSliceWithProfilesProgress: 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_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"], + ) diff --git a/backend/tests/unit/test_slicer_presets.py b/backend/tests/unit/test_slicer_presets.py index c74a8e1d7..d537ce01e 100644 --- a/backend/tests/unit/test_slicer_presets.py +++ b/backend/tests/unit/test_slicer_presets.py @@ -431,3 +431,208 @@ class TestResolveSlicerApiUrl: ): url = await sp._resolve_slicer_api_url(MagicMock()) assert url is None + + +class TestBundleRoutes: + """Route-level coverage for the bundle proxy endpoints. Each route + resolves the sidecar URL via _resolve_slicer_api_url, then proxies the + operation through SlicerApiService. We mock both pieces so we can pin + the HTTP-status mapping (sidecar input error → 400, BundleNotFoundError + → 404, unreachable → 503) without spinning up a sidecar. + """ + + SAMPLE_SUMMARY = sp.BundleSummary( + id="abc123def456abcd", + 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", + ) + + def _patched_service(self, **methods) -> MagicMock: + """Build a SlicerApiService mock that supports `async with` and + exposes the bundle methods via AsyncMock per the override dict.""" + svc = MagicMock() + svc.__aenter__ = AsyncMock(return_value=svc) + svc.__aexit__ = AsyncMock(return_value=False) + for name, mock in methods.items(): + setattr(svc, name, mock) + return svc + + @pytest.mark.asyncio + async def test_import_bundle_happy_path(self): + from io import BytesIO + + from fastapi import UploadFile + + svc = self._patched_service( + import_bundle=AsyncMock(return_value=self.SAMPLE_SUMMARY), + ) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc), + ): + file = UploadFile(filename="H2D.bbscfg", file=BytesIO(b"PK\x03\x04")) + result = await sp.import_slicer_bundle(file=file, db=MagicMock(), _=None) + assert result["id"] == "abc123def456abcd" + assert result["printer"] == ["# Bambu Lab H2D 0.4 nozzle"] + svc.import_bundle.assert_awaited_once() + kwargs = svc.import_bundle.await_args.kwargs + assert kwargs["filename"] == "H2D.bbscfg" + + @pytest.mark.asyncio + async def test_import_bundle_no_sidecar_returns_503(self): + from io import BytesIO + + from fastapi import HTTPException, UploadFile + + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)), + pytest.raises(HTTPException) as exc, + ): + await sp.import_slicer_bundle( + file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")), + db=MagicMock(), + _=None, + ) + assert exc.value.status_code == 503 + + @pytest.mark.asyncio + async def test_import_bundle_empty_file_returns_400(self): + from io import BytesIO + + from fastapi import HTTPException, UploadFile + + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + pytest.raises(HTTPException) as exc, + ): + await sp.import_slicer_bundle( + file=UploadFile(filename="x.bbscfg", file=BytesIO(b"")), + db=MagicMock(), + _=None, + ) + assert exc.value.status_code == 400 + + @pytest.mark.asyncio + async def test_import_bundle_sidecar_400_passes_through(self): + from io import BytesIO + + from fastapi import HTTPException, UploadFile + + svc = self._patched_service( + import_bundle=AsyncMock(side_effect=sp.SlicerInputError("bad zip")), + ) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await sp.import_slicer_bundle( + file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")), + db=MagicMock(), + _=None, + ) + assert exc.value.status_code == 400 + + @pytest.mark.asyncio + async def test_import_bundle_sidecar_unreachable_returns_503(self): + from io import BytesIO + + from fastapi import HTTPException, UploadFile + + svc = self._patched_service( + import_bundle=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")), + ) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await sp.import_slicer_bundle( + file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")), + db=MagicMock(), + _=None, + ) + assert exc.value.status_code == 503 + + @pytest.mark.asyncio + async def test_list_bundles_happy_path(self): + svc = self._patched_service( + list_bundles=AsyncMock(return_value=[self.SAMPLE_SUMMARY]), + ) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc), + ): + result = await sp.list_slicer_bundles(db=MagicMock(), _=None) + assert len(result) == 1 + assert result[0]["id"] == "abc123def456abcd" + + @pytest.mark.asyncio + async def test_list_bundles_no_sidecar_returns_empty(self): + # Differs from import: list returns [] instead of 503 so the + # SliceModal still renders cleanly when no sidecar is configured + # (matches bundled-tier behaviour above). + with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)): + result = await sp.list_slicer_bundles(db=MagicMock(), _=None) + assert result == [] + + @pytest.mark.asyncio + async def test_list_bundles_sidecar_unreachable_returns_503(self): + from fastapi import HTTPException + + svc = self._patched_service( + list_bundles=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")), + ) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await sp.list_slicer_bundles(db=MagicMock(), _=None) + assert exc.value.status_code == 503 + + @pytest.mark.asyncio + async def test_get_bundle_404(self): + from fastapi import HTTPException + + svc = self._patched_service( + get_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")), + ) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await sp.get_slicer_bundle("missing", db=MagicMock(), _=None) + assert exc.value.status_code == 404 + + @pytest.mark.asyncio + async def test_delete_bundle_204(self): + # delete returns None on success; FastAPI sends 204 because the route + # declares status_code=204. + svc = self._patched_service(delete_bundle=AsyncMock(return_value=None)) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc), + ): + result = await sp.delete_slicer_bundle("abc", db=MagicMock(), _=None) + assert result is None + svc.delete_bundle.assert_awaited_once_with("abc") + + @pytest.mark.asyncio + async def test_delete_bundle_404(self): + from fastapi import HTTPException + + svc = self._patched_service( + delete_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")), + ) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await sp.delete_slicer_bundle("missing", db=MagicMock(), _=None) + assert exc.value.status_code == 404