mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
strips system processes/filaments, so importing a bundle left users without
process presets and slicing fell back to embedded settings on STL. Bundle
mode also hid the standard tier behind a constrained dropdown, the actual
trap reported here.
Removed end-to-end:
- backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
SliceBundleSpec, dispatch fork in library.py, bundle-context params on
the filament-requirements endpoints, bundle-fingerprint cache key in
slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
slice_with_bundle, BundleSummary / BundleNotFoundError.
- frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
loses its bundle path; presetCompatibility keeps compatible_printers
plus the @BBL fallback.
- SlicerBundlesPanel turns into a permanent static notice explaining the
removal, alternative import paths, and the new slice-time lookup order
(Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
- i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
translated across all 11 locales; slice.bundle*, slicerBundles.* keys
removed.
Fixed (surfaced by removing bundle mode):
- _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
from: "system" on the payload before json.dumps. Bambu Cloud ships
type as "printer"/"print" and routinely empty `from`; the BS CLI's
--load-settings parser rejects both with return -5 / "input preset
file invalid". Standard tier already did this; cloud paths now match.
438 lines
17 KiB
Python
438 lines
17 KiB
Python
"""Tests for the source-aware preset resolver used by the slice route."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from backend.app.schemas.slicer import PresetRef
|
|
from backend.app.services import preset_resolver
|
|
|
|
# --- standard tier --------------------------------------------------------
|
|
|
|
|
|
def test_standard_emits_inherits_stub():
|
|
"""Standard tier returns a JSON stub the sidecar's resolver can flatten
|
|
against `BUNDLED_PROFILES_PATH/<category>/<name>.json`. No content
|
|
round-trip needed — the sidecar reads the bundled JSON itself."""
|
|
out = preset_resolver._resolve_standard(
|
|
PresetRef(source="standard", id="Bambu Lab X1 Carbon 0.4 nozzle"),
|
|
slot="printer",
|
|
)
|
|
payload = json.loads(out)
|
|
assert payload == {
|
|
"name": "Bambu Lab X1 Carbon 0.4 nozzle",
|
|
"inherits": "Bambu Lab X1 Carbon 0.4 nozzle",
|
|
# `from: "system"` so the sidecar's compatibility check doesn't
|
|
# treat this as a User-authored profile and reject it against
|
|
# system filament/process pairs.
|
|
"from": "system",
|
|
# `type` is required by the CLI's --load-settings parser. Without
|
|
# it the CLI silently exits with rc=-5 ("input preset file is
|
|
# invalid"), causing every 3MF slice to fall back to embedded
|
|
# settings. See preset_resolver._SLOT_TO_PROFILE_TYPE.
|
|
"type": "machine",
|
|
}
|
|
|
|
|
|
def test_standard_emits_correct_type_per_slot():
|
|
"""Each slot maps to the right `type` value the CLI parser expects:
|
|
printer → machine, process → process, filament → filament. Missing or
|
|
wrong type causes the CLI to silently exit with rc=-5."""
|
|
for slot, expected_type in (("printer", "machine"), ("process", "process"), ("filament", "filament")):
|
|
out = preset_resolver._resolve_standard(
|
|
PresetRef(source="standard", id="anything"),
|
|
slot=slot,
|
|
)
|
|
assert json.loads(out)["type"] == expected_type, slot
|
|
|
|
|
|
def test_standard_rejects_unknown_slot():
|
|
with pytest.raises(HTTPException) as exc:
|
|
preset_resolver._resolve_standard(PresetRef(source="standard", id="anything"), slot="bogus")
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
# --- local tier -----------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_returns_setting_blob():
|
|
db = MagicMock()
|
|
preset = MagicMock()
|
|
preset.preset_type = "filament"
|
|
preset.setting = '{"name": "PLA Basic"}'
|
|
db.get = AsyncMock(return_value=preset)
|
|
|
|
out = await preset_resolver._resolve_local(db, PresetRef(source="local", id="42"), slot="filament")
|
|
assert out == '{"name": "PLA Basic"}'
|
|
db.get.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_rejects_non_integer_id():
|
|
db = MagicMock()
|
|
db.get = AsyncMock()
|
|
with pytest.raises(HTTPException) as exc:
|
|
await preset_resolver._resolve_local(db, PresetRef(source="local", id="not-a-number"), slot="filament")
|
|
assert exc.value.status_code == 400
|
|
db.get.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_rejects_wrong_preset_type():
|
|
"""A `local` ref pointing at a process preset for the filament slot
|
|
must fail — same guard the legacy slice path had."""
|
|
db = MagicMock()
|
|
preset = MagicMock()
|
|
preset.preset_type = "process"
|
|
db.get = AsyncMock(return_value=preset)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await preset_resolver._resolve_local(db, PresetRef(source="local", id="1"), slot="filament")
|
|
assert exc.value.status_code == 400
|
|
assert "preset_type='filament'" in exc.value.detail
|
|
|
|
|
|
# --- cloud tier -----------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_blocks_user_without_cloud_auth():
|
|
"""Defence-in-depth: a user holding LIBRARY_UPLOAD but not CLOUD_AUTH
|
|
cannot slice with cloud presets even if their User row carries a
|
|
leftover cloud_token from a previous permission state."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=False)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_400_when_no_token_stored():
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=(None, None, None)),
|
|
),
|
|
pytest.raises(HTTPException) as exc,
|
|
):
|
|
await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
assert exc.value.status_code == 400
|
|
assert "Sign in" in exc.value.detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_unwraps_setting_envelope():
|
|
"""Bambu Cloud's `get_setting_detail` returns the preset wrapped under
|
|
`.setting`; the sidecar wants the inner content, not the envelope."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
cloud_mock = MagicMock()
|
|
cloud_mock.set_token = MagicMock()
|
|
cloud_mock.get_setting_detail = AsyncMock(
|
|
return_value={
|
|
"setting_id": "PFU123",
|
|
"name": "X1C Custom",
|
|
"setting": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
|
|
}
|
|
)
|
|
cloud_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=("tok", "e@x", "global")),
|
|
),
|
|
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
|
|
):
|
|
out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
payload = json.loads(out)
|
|
# Resolver rewrites the `type` field to the CLI-expected value AND pins
|
|
# `from: "system"` (#1712 follow-up: Bambu Cloud labels printers as
|
|
# "printer" and filaments routinely ship with empty `from`; the CLI
|
|
# rejects either with the same -5 "input preset invalid" surface).
|
|
assert payload == {
|
|
"name": "X1C Custom",
|
|
"nozzle_diameter": [0.4],
|
|
"type": "machine",
|
|
"from": "system",
|
|
}
|
|
cloud_mock.close.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_falls_back_to_top_level_when_no_envelope():
|
|
"""If a cloud response doesn't nest under `.setting` (rare but seen on
|
|
some endpoints), forward the whole payload rather than failing — the
|
|
sidecar will reject malformed content cleanly."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
cloud_mock = MagicMock()
|
|
cloud_mock.set_token = MagicMock()
|
|
cloud_mock.get_setting_detail = AsyncMock(return_value={"name": "X1C Custom", "nozzle_diameter": [0.4]})
|
|
cloud_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=("tok", None, "global")),
|
|
),
|
|
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
|
|
):
|
|
out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
payload = json.loads(out)
|
|
assert "name" in payload
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"slot, source_type, expected_type",
|
|
[
|
|
# Bambu Cloud's wire shape: `printer` / `print` / `filament`. The CLI
|
|
# only accepts `machine` / `process` / `filament`. Without rewrite
|
|
# the CLI exits -5 with `operator(): unknown config type` and the
|
|
# sidecar surfaces "The input preset file is invalid and can not be
|
|
# parsed" (#1712 follow-up, reported by maziggy on Mecha Mewtwo).
|
|
("printer", "printer", "machine"),
|
|
("process", "print", "process"),
|
|
("filament", "filament", "filament"),
|
|
# Cloud-side already CLI-shaped: still gets overwritten to the
|
|
# canonical value — idempotent, no harm.
|
|
("printer", "machine", "machine"),
|
|
("process", "process", "process"),
|
|
# Missing type field on the source payload: synthesise it.
|
|
("printer", None, "machine"),
|
|
("process", None, "process"),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_rewrites_type_field_for_cli(slot, source_type, expected_type):
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
setting: dict = {"name": "P"}
|
|
if source_type is not None:
|
|
setting["type"] = source_type
|
|
cloud_mock = MagicMock()
|
|
cloud_mock.set_token = MagicMock()
|
|
cloud_mock.get_setting_detail = AsyncMock(return_value={"setting": setting})
|
|
cloud_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=("tok", None, "global")),
|
|
),
|
|
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
|
|
):
|
|
out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="X"), slot=slot)
|
|
assert json.loads(out)["type"] == expected_type
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"source_from",
|
|
[
|
|
# The actual failing case (#1712 follow-up): Bambu Cloud's filament
|
|
# detail endpoint routinely returns presets with no `from` field or
|
|
# `from: ""`. The CLI rejects either with
|
|
# `operator(): ... from unsupported` (note the double space — that's
|
|
# the literal stderr from the sidecar log on the Mecha Mewtwo slice).
|
|
"",
|
|
# Cloud-side already CLI-friendly: still gets pinned to "system" —
|
|
# idempotent, no harm, matches the standard-tier convention.
|
|
"system",
|
|
# GUI-exported values that the sidecar's normalizeFromField also
|
|
# maps to "system" for the same reason — we beat it to the punch.
|
|
"User",
|
|
"System",
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_pins_from_field_to_system(source_from):
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
setting: dict = {"name": "F", "type": "filament", "from": source_from}
|
|
cloud_mock = MagicMock()
|
|
cloud_mock.set_token = MagicMock()
|
|
cloud_mock.get_setting_detail = AsyncMock(return_value={"setting": setting})
|
|
cloud_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=("tok", None, "global")),
|
|
),
|
|
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
|
|
):
|
|
out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="X"), slot="filament")
|
|
assert json.loads(out)["from"] == "system"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_synthesises_from_field_when_missing():
|
|
"""The original failing payload had no `from` field at all (sidecar
|
|
error: `from unsupported` — double space = empty value). The resolver
|
|
must still emit a usable `from` instead of forwarding the gap."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
setting = {"name": "F", "type": "filament"} # NB: no `from`
|
|
cloud_mock = MagicMock()
|
|
cloud_mock.set_token = MagicMock()
|
|
cloud_mock.get_setting_detail = AsyncMock(return_value={"setting": setting})
|
|
cloud_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=("tok", None, "global")),
|
|
),
|
|
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
|
|
):
|
|
out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="X"), slot="filament")
|
|
assert json.loads(out)["from"] == "system"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_auth_error_returns_401():
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
cloud_mock = MagicMock()
|
|
cloud_mock.set_token = MagicMock()
|
|
cloud_mock.get_setting_detail = AsyncMock(side_effect=preset_resolver.BambuCloudAuthError("expired"))
|
|
cloud_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(
|
|
preset_resolver,
|
|
"get_stored_token",
|
|
AsyncMock(return_value=("tok", None, "global")),
|
|
),
|
|
patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
|
|
pytest.raises(HTTPException) as exc,
|
|
):
|
|
await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
|
|
assert exc.value.status_code == 401
|
|
|
|
|
|
# --- orca_cloud tier -------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orca_cloud_blocks_user_without_orca_cloud_auth():
|
|
"""Defence-in-depth, same shape as the Bambu Cloud permission check:
|
|
a user holding LIBRARY_UPLOAD but not ORCA_CLOUD_AUTH can't slice with
|
|
Orca Cloud presets even if their User row still carries a token."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=False)
|
|
with pytest.raises(HTTPException) as exc:
|
|
await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orca_cloud_unwraps_content():
|
|
"""Orca's profile shape is ``{id, name, content, updated_time, created_time}``
|
|
— the inner ``content`` is the actual slicer-format JSON. We forward
|
|
that, not the wrapper."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
svc_mock = MagicMock()
|
|
svc_mock.get_profile = AsyncMock(
|
|
return_value={
|
|
"id": "abc",
|
|
"name": "X1C Custom",
|
|
"content": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
|
|
}
|
|
)
|
|
svc_mock.close = AsyncMock()
|
|
with patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)):
|
|
out = await preset_resolver._resolve_orca_cloud(
|
|
db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer"
|
|
)
|
|
payload = json.loads(out)
|
|
# Resolver rewrites `type` to the CLI-expected value AND pins
|
|
# `from: "system"` (#1712 follow-up). Orca natively uses "machine" but
|
|
# Bambu-sourced syncs can carry "printer" and either empty/missing `from`.
|
|
assert payload == {
|
|
"name": "X1C Custom",
|
|
"nozzle_diameter": [0.4],
|
|
"type": "machine",
|
|
"from": "system",
|
|
}
|
|
svc_mock.close.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orca_cloud_auth_error_returns_401():
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
svc_mock = MagicMock()
|
|
svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudAuthError("expired"))
|
|
svc_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
|
|
pytest.raises(HTTPException) as exc,
|
|
):
|
|
await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
|
|
assert exc.value.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orca_cloud_not_found_returns_400():
|
|
"""``get_profile`` raises generic ``OrcaCloudError`` for "not found" —
|
|
the resolver maps that to a 400 (not 502) so the UI can show "profile
|
|
no longer exists" rather than "service down"."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
svc_mock = MagicMock()
|
|
svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudError("profile 'abc' not found (scanned 0)"))
|
|
svc_mock.close = AsyncMock()
|
|
with (
|
|
patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
|
|
pytest.raises(HTTPException) as exc,
|
|
):
|
|
await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
# --- top-level dispatcher -------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_preset_ref_dispatches_by_source():
|
|
"""The public entrypoint just routes to the right tier-specific
|
|
helper. Verify each branch is selected correctly."""
|
|
db = MagicMock()
|
|
user = MagicMock()
|
|
user.has_permission = MagicMock(return_value=True)
|
|
preset = MagicMock()
|
|
preset.preset_type = "printer"
|
|
preset.setting = '{"local": true}'
|
|
db.get = AsyncMock(return_value=preset)
|
|
|
|
# local
|
|
out = await preset_resolver.resolve_preset_ref(db, user, PresetRef(source="local", id="1"), slot="printer")
|
|
assert out == '{"local": true}'
|
|
|
|
# standard
|
|
out = await preset_resolver.resolve_preset_ref(
|
|
db, user, PresetRef(source="standard", id="Some Bundled Name"), slot="printer"
|
|
)
|
|
assert json.loads(out)["inherits"] == "Some Bundled Name"
|