| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- """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"
|