Browse Source

feat(slicer): add /slicer/bundles routes for .bbscfg import + bundle slicing

  Wires Bambuddy to the orca-slicer-api fork's bundle endpoints (shipped
  in bambuddy/bundle-import). Users will eventually upload a BambuStudio
  "Printer Preset Bundle" (.bbscfg) once per printer; subsequent slices
  pick from the bundle by preset name instead of re-uploading the JSON
  triplet every time.

  Service layer:
  - BundleSummary / BundleNotFoundError types
  - import_bundle / list_bundles / get_bundle / delete_bundle methods
  - slice_with_bundle: POST /slice with bundle id + per-category names
    instead of attached profile JSONs

  Routes (LIBRARY_UPLOAD perm gate):
  - POST   /api/v1/slicer/bundles
  - GET    /api/v1/slicer/bundles
  - GET    /api/v1/slicer/bundles/:id
  - DELETE /api/v1/slicer/bundles/:id

  All routes proxy via _resolve_slicer_api_url so they follow the user's
  preferred_slicer setting (bambu_studio vs orcaslicer). Status-code
  mapping treats sidecar 4xx as 400, BundleNotFoundError as 404,
  unreachable as 503, and sidecar 5xx as 502.
maziggy 3 weeks ago
parent
commit
060ba509da

+ 148 - 8
backend/app/api/routes/slicer_presets.py

@@ -16,7 +16,7 @@ import json
 import logging
 import logging
 import time
 import time
 
 
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
@@ -37,7 +37,14 @@ from backend.app.services.bambu_cloud import (
     BambuCloudError,
     BambuCloudError,
     BambuCloudService,
     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__)
 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}")
 @router.get("/preview-progress/{request_id}")
 async def get_preview_slice_progress(
 async def get_preview_slice_progress(
     request_id: str,
     request_id: str,
@@ -392,8 +538,6 @@ async def get_preview_slice_progress(
 
 
     api_url = await _resolve_slicer_api_url(db)
     api_url = await _resolve_slicer_api_url(db)
     if not api_url:
     if not api_url:
-        from fastapi import HTTPException
-
         raise HTTPException(status_code=503, detail="No slicer sidecar configured")
         raise HTTPException(status_code=503, detail="No slicer sidecar configured")
     url = f"{api_url}/slice/progress/{request_id}"
     url = f"{api_url}/slice/progress/{request_id}"
     try:
     try:
@@ -402,11 +546,7 @@ async def get_preview_slice_progress(
     except httpx.RequestError:
     except httpx.RequestError:
         # Sidecar unreachable: surface as 503 instead of 500 so the
         # Sidecar unreachable: surface as 503 instead of 500 so the
         # frontend's poller can keep trying without flagging a hard error.
         # 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
         raise HTTPException(status_code=503, detail="Slicer sidecar unreachable") from None
     if response.status_code == 404:
     if response.status_code == 404:
-        from fastapi import HTTPException
-
         raise HTTPException(status_code=404, detail="Progress unavailable")
         raise HTTPException(status_code=404, detail="Progress unavailable")
     return response.json()
     return response.json()

+ 223 - 0
backend/app/services/slicer_api.py

@@ -47,6 +47,42 @@ class SliceResult(NamedTuple):
     filament_used_mm: float
     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
 _shared_http_client: httpx.AsyncClient | None = None
 
 
 
 
@@ -155,6 +191,102 @@ class SlicerApiService:
             raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
             raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
         return response.json()
         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/<id> — 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/<id> — 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(
     async def _poll_progress(
         self,
         self,
         request_id: str,
         request_id: str,
@@ -292,6 +424,97 @@ class SlicerApiService:
             filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
             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(
     async def slice_without_profiles(
         self,
         self,
         *,
         *,

+ 245 - 0
backend/tests/unit/services/test_slicer_api.py

@@ -8,6 +8,8 @@ import httpx
 import pytest
 import pytest
 
 
 from backend.app.services.slicer_api import (
 from backend.app.services.slicer_api import (
+    BundleNotFoundError,
+    BundleSummary,
     SlicerApiServerError,
     SlicerApiServerError,
     SlicerApiService,
     SlicerApiService,
     SlicerApiUnavailableError,
     SlicerApiUnavailableError,
@@ -479,3 +481,246 @@ class TestSliceWithProfilesProgress:
         assert result is not None
         assert result is not None
         # Sustained 404 → no snapshots ever forwarded.
         # Sustained 404 → no snapshots ever forwarded.
         assert snapshots == []
         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"],
+            )

+ 205 - 0
backend/tests/unit/test_slicer_presets.py

@@ -431,3 +431,208 @@ class TestResolveSlicerApiUrl:
         ):
         ):
             url = await sp._resolve_slicer_api_url(MagicMock())
             url = await sp._resolve_slicer_api_url(MagicMock())
         assert url is None
         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