test_preset_resolver.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. """Tests for the source-aware preset resolver used by the slice route."""
  2. from __future__ import annotations
  3. import json
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from fastapi import HTTPException
  7. from backend.app.schemas.slicer import PresetRef
  8. from backend.app.services import preset_resolver
  9. # --- standard tier --------------------------------------------------------
  10. def test_standard_emits_inherits_stub():
  11. """Standard tier returns a JSON stub the sidecar's resolver can flatten
  12. against `BUNDLED_PROFILES_PATH/<category>/<name>.json`. No content
  13. round-trip needed — the sidecar reads the bundled JSON itself."""
  14. out = preset_resolver._resolve_standard(
  15. PresetRef(source="standard", id="Bambu Lab X1 Carbon 0.4 nozzle"),
  16. slot="printer",
  17. )
  18. payload = json.loads(out)
  19. assert payload == {
  20. "name": "Bambu Lab X1 Carbon 0.4 nozzle",
  21. "inherits": "Bambu Lab X1 Carbon 0.4 nozzle",
  22. # `from: "system"` so the sidecar's compatibility check doesn't
  23. # treat this as a User-authored profile and reject it against
  24. # system filament/process pairs.
  25. "from": "system",
  26. # `type` is required by the CLI's --load-settings parser. Without
  27. # it the CLI silently exits with rc=-5 ("input preset file is
  28. # invalid"), causing every 3MF slice to fall back to embedded
  29. # settings. See preset_resolver._SLOT_TO_PROFILE_TYPE.
  30. "type": "machine",
  31. }
  32. def test_standard_emits_correct_type_per_slot():
  33. """Each slot maps to the right `type` value the CLI parser expects:
  34. printer → machine, process → process, filament → filament. Missing or
  35. wrong type causes the CLI to silently exit with rc=-5."""
  36. for slot, expected_type in (("printer", "machine"), ("process", "process"), ("filament", "filament")):
  37. out = preset_resolver._resolve_standard(
  38. PresetRef(source="standard", id="anything"),
  39. slot=slot,
  40. )
  41. assert json.loads(out)["type"] == expected_type, slot
  42. def test_standard_rejects_unknown_slot():
  43. with pytest.raises(HTTPException) as exc:
  44. preset_resolver._resolve_standard(PresetRef(source="standard", id="anything"), slot="bogus")
  45. assert exc.value.status_code == 400
  46. # --- local tier -----------------------------------------------------------
  47. @pytest.mark.asyncio
  48. async def test_local_returns_setting_blob():
  49. db = MagicMock()
  50. preset = MagicMock()
  51. preset.preset_type = "filament"
  52. preset.setting = '{"name": "PLA Basic"}'
  53. db.get = AsyncMock(return_value=preset)
  54. out = await preset_resolver._resolve_local(db, PresetRef(source="local", id="42"), slot="filament")
  55. assert out == '{"name": "PLA Basic"}'
  56. db.get.assert_awaited_once()
  57. @pytest.mark.asyncio
  58. async def test_local_rejects_non_integer_id():
  59. db = MagicMock()
  60. db.get = AsyncMock()
  61. with pytest.raises(HTTPException) as exc:
  62. await preset_resolver._resolve_local(db, PresetRef(source="local", id="not-a-number"), slot="filament")
  63. assert exc.value.status_code == 400
  64. db.get.assert_not_awaited()
  65. @pytest.mark.asyncio
  66. async def test_local_rejects_wrong_preset_type():
  67. """A `local` ref pointing at a process preset for the filament slot
  68. must fail — same guard the legacy slice path had."""
  69. db = MagicMock()
  70. preset = MagicMock()
  71. preset.preset_type = "process"
  72. db.get = AsyncMock(return_value=preset)
  73. with pytest.raises(HTTPException) as exc:
  74. await preset_resolver._resolve_local(db, PresetRef(source="local", id="1"), slot="filament")
  75. assert exc.value.status_code == 400
  76. assert "preset_type='filament'" in exc.value.detail
  77. # --- cloud tier -----------------------------------------------------------
  78. @pytest.mark.asyncio
  79. async def test_cloud_blocks_user_without_cloud_auth():
  80. """Defence-in-depth: a user holding LIBRARY_UPLOAD but not CLOUD_AUTH
  81. cannot slice with cloud presets even if their User row carries a
  82. leftover cloud_token from a previous permission state."""
  83. db = MagicMock()
  84. user = MagicMock()
  85. user.has_permission = MagicMock(return_value=False)
  86. with pytest.raises(HTTPException) as exc:
  87. await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  88. assert exc.value.status_code == 403
  89. @pytest.mark.asyncio
  90. async def test_cloud_400_when_no_token_stored():
  91. db = MagicMock()
  92. user = MagicMock()
  93. user.has_permission = MagicMock(return_value=True)
  94. with (
  95. patch.object(
  96. preset_resolver,
  97. "get_stored_token",
  98. AsyncMock(return_value=(None, None, None)),
  99. ),
  100. pytest.raises(HTTPException) as exc,
  101. ):
  102. await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  103. assert exc.value.status_code == 400
  104. assert "Sign in" in exc.value.detail
  105. @pytest.mark.asyncio
  106. async def test_cloud_unwraps_setting_envelope():
  107. """Bambu Cloud's `get_setting_detail` returns the preset wrapped under
  108. `.setting`; the sidecar wants the inner content, not the envelope."""
  109. db = MagicMock()
  110. user = MagicMock()
  111. user.has_permission = MagicMock(return_value=True)
  112. cloud_mock = MagicMock()
  113. cloud_mock.set_token = MagicMock()
  114. cloud_mock.get_setting_detail = AsyncMock(
  115. return_value={
  116. "setting_id": "PFU123",
  117. "name": "X1C Custom",
  118. "setting": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
  119. }
  120. )
  121. cloud_mock.close = AsyncMock()
  122. with (
  123. patch.object(
  124. preset_resolver,
  125. "get_stored_token",
  126. AsyncMock(return_value=("tok", "e@x", "global")),
  127. ),
  128. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  129. ):
  130. out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  131. payload = json.loads(out)
  132. # Resolver rewrites the `type` field to the CLI-expected value AND pins
  133. # `from: "system"` (#1712 follow-up: Bambu Cloud labels printers as
  134. # "printer" and filaments routinely ship with empty `from`; the CLI
  135. # rejects either with the same -5 "input preset invalid" surface).
  136. assert payload == {
  137. "name": "X1C Custom",
  138. "nozzle_diameter": [0.4],
  139. "type": "machine",
  140. "from": "system",
  141. }
  142. cloud_mock.close.assert_awaited_once()
  143. @pytest.mark.asyncio
  144. async def test_cloud_falls_back_to_top_level_when_no_envelope():
  145. """If a cloud response doesn't nest under `.setting` (rare but seen on
  146. some endpoints), forward the whole payload rather than failing — the
  147. sidecar will reject malformed content cleanly."""
  148. db = MagicMock()
  149. user = MagicMock()
  150. user.has_permission = MagicMock(return_value=True)
  151. cloud_mock = MagicMock()
  152. cloud_mock.set_token = MagicMock()
  153. cloud_mock.get_setting_detail = AsyncMock(return_value={"name": "X1C Custom", "nozzle_diameter": [0.4]})
  154. cloud_mock.close = AsyncMock()
  155. with (
  156. patch.object(
  157. preset_resolver,
  158. "get_stored_token",
  159. AsyncMock(return_value=("tok", None, "global")),
  160. ),
  161. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  162. ):
  163. out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  164. payload = json.loads(out)
  165. assert "name" in payload
  166. @pytest.mark.parametrize(
  167. "slot, source_type, expected_type",
  168. [
  169. # Bambu Cloud's wire shape: `printer` / `print` / `filament`. The CLI
  170. # only accepts `machine` / `process` / `filament`. Without rewrite
  171. # the CLI exits -5 with `operator(): unknown config type` and the
  172. # sidecar surfaces "The input preset file is invalid and can not be
  173. # parsed" (#1712 follow-up, reported by maziggy on Mecha Mewtwo).
  174. ("printer", "printer", "machine"),
  175. ("process", "print", "process"),
  176. ("filament", "filament", "filament"),
  177. # Cloud-side already CLI-shaped: still gets overwritten to the
  178. # canonical value — idempotent, no harm.
  179. ("printer", "machine", "machine"),
  180. ("process", "process", "process"),
  181. # Missing type field on the source payload: synthesise it.
  182. ("printer", None, "machine"),
  183. ("process", None, "process"),
  184. ],
  185. )
  186. @pytest.mark.asyncio
  187. async def test_cloud_rewrites_type_field_for_cli(slot, source_type, expected_type):
  188. db = MagicMock()
  189. user = MagicMock()
  190. user.has_permission = MagicMock(return_value=True)
  191. setting: dict = {"name": "P"}
  192. if source_type is not None:
  193. setting["type"] = source_type
  194. cloud_mock = MagicMock()
  195. cloud_mock.set_token = MagicMock()
  196. cloud_mock.get_setting_detail = AsyncMock(return_value={"setting": setting})
  197. cloud_mock.close = AsyncMock()
  198. with (
  199. patch.object(
  200. preset_resolver,
  201. "get_stored_token",
  202. AsyncMock(return_value=("tok", None, "global")),
  203. ),
  204. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  205. ):
  206. out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="X"), slot=slot)
  207. assert json.loads(out)["type"] == expected_type
  208. @pytest.mark.parametrize(
  209. "source_from",
  210. [
  211. # The actual failing case (#1712 follow-up): Bambu Cloud's filament
  212. # detail endpoint routinely returns presets with no `from` field or
  213. # `from: ""`. The CLI rejects either with
  214. # `operator(): ... from unsupported` (note the double space — that's
  215. # the literal stderr from the sidecar log on the Mecha Mewtwo slice).
  216. "",
  217. # Cloud-side already CLI-friendly: still gets pinned to "system" —
  218. # idempotent, no harm, matches the standard-tier convention.
  219. "system",
  220. # GUI-exported values that the sidecar's normalizeFromField also
  221. # maps to "system" for the same reason — we beat it to the punch.
  222. "User",
  223. "System",
  224. ],
  225. )
  226. @pytest.mark.asyncio
  227. async def test_cloud_pins_from_field_to_system(source_from):
  228. db = MagicMock()
  229. user = MagicMock()
  230. user.has_permission = MagicMock(return_value=True)
  231. setting: dict = {"name": "F", "type": "filament", "from": source_from}
  232. cloud_mock = MagicMock()
  233. cloud_mock.set_token = MagicMock()
  234. cloud_mock.get_setting_detail = AsyncMock(return_value={"setting": setting})
  235. cloud_mock.close = AsyncMock()
  236. with (
  237. patch.object(
  238. preset_resolver,
  239. "get_stored_token",
  240. AsyncMock(return_value=("tok", None, "global")),
  241. ),
  242. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  243. ):
  244. out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="X"), slot="filament")
  245. assert json.loads(out)["from"] == "system"
  246. @pytest.mark.asyncio
  247. async def test_cloud_synthesises_from_field_when_missing():
  248. """The original failing payload had no `from` field at all (sidecar
  249. error: `from unsupported` — double space = empty value). The resolver
  250. must still emit a usable `from` instead of forwarding the gap."""
  251. db = MagicMock()
  252. user = MagicMock()
  253. user.has_permission = MagicMock(return_value=True)
  254. setting = {"name": "F", "type": "filament"} # NB: no `from`
  255. cloud_mock = MagicMock()
  256. cloud_mock.set_token = MagicMock()
  257. cloud_mock.get_setting_detail = AsyncMock(return_value={"setting": setting})
  258. cloud_mock.close = AsyncMock()
  259. with (
  260. patch.object(
  261. preset_resolver,
  262. "get_stored_token",
  263. AsyncMock(return_value=("tok", None, "global")),
  264. ),
  265. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  266. ):
  267. out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="X"), slot="filament")
  268. assert json.loads(out)["from"] == "system"
  269. @pytest.mark.asyncio
  270. async def test_cloud_auth_error_returns_401():
  271. db = MagicMock()
  272. user = MagicMock()
  273. user.has_permission = MagicMock(return_value=True)
  274. cloud_mock = MagicMock()
  275. cloud_mock.set_token = MagicMock()
  276. cloud_mock.get_setting_detail = AsyncMock(side_effect=preset_resolver.BambuCloudAuthError("expired"))
  277. cloud_mock.close = AsyncMock()
  278. with (
  279. patch.object(
  280. preset_resolver,
  281. "get_stored_token",
  282. AsyncMock(return_value=("tok", None, "global")),
  283. ),
  284. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  285. pytest.raises(HTTPException) as exc,
  286. ):
  287. await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  288. assert exc.value.status_code == 401
  289. # --- orca_cloud tier -------------------------------------------------------
  290. @pytest.mark.asyncio
  291. async def test_orca_cloud_blocks_user_without_orca_cloud_auth():
  292. """Defence-in-depth, same shape as the Bambu Cloud permission check:
  293. a user holding LIBRARY_UPLOAD but not ORCA_CLOUD_AUTH can't slice with
  294. Orca Cloud presets even if their User row still carries a token."""
  295. db = MagicMock()
  296. user = MagicMock()
  297. user.has_permission = MagicMock(return_value=False)
  298. with pytest.raises(HTTPException) as exc:
  299. await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
  300. assert exc.value.status_code == 403
  301. @pytest.mark.asyncio
  302. async def test_orca_cloud_unwraps_content():
  303. """Orca's profile shape is ``{id, name, content, updated_time, created_time}``
  304. — the inner ``content`` is the actual slicer-format JSON. We forward
  305. that, not the wrapper."""
  306. db = MagicMock()
  307. user = MagicMock()
  308. user.has_permission = MagicMock(return_value=True)
  309. svc_mock = MagicMock()
  310. svc_mock.get_profile = AsyncMock(
  311. return_value={
  312. "id": "abc",
  313. "name": "X1C Custom",
  314. "content": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
  315. }
  316. )
  317. svc_mock.close = AsyncMock()
  318. with patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)):
  319. out = await preset_resolver._resolve_orca_cloud(
  320. db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer"
  321. )
  322. payload = json.loads(out)
  323. # Resolver rewrites `type` to the CLI-expected value AND pins
  324. # `from: "system"` (#1712 follow-up). Orca natively uses "machine" but
  325. # Bambu-sourced syncs can carry "printer" and either empty/missing `from`.
  326. assert payload == {
  327. "name": "X1C Custom",
  328. "nozzle_diameter": [0.4],
  329. "type": "machine",
  330. "from": "system",
  331. }
  332. svc_mock.close.assert_awaited_once()
  333. @pytest.mark.asyncio
  334. async def test_orca_cloud_auth_error_returns_401():
  335. db = MagicMock()
  336. user = MagicMock()
  337. user.has_permission = MagicMock(return_value=True)
  338. svc_mock = MagicMock()
  339. svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudAuthError("expired"))
  340. svc_mock.close = AsyncMock()
  341. with (
  342. patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  343. pytest.raises(HTTPException) as exc,
  344. ):
  345. await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
  346. assert exc.value.status_code == 401
  347. @pytest.mark.asyncio
  348. async def test_orca_cloud_not_found_returns_400():
  349. """``get_profile`` raises generic ``OrcaCloudError`` for "not found" —
  350. the resolver maps that to a 400 (not 502) so the UI can show "profile
  351. no longer exists" rather than "service down"."""
  352. db = MagicMock()
  353. user = MagicMock()
  354. user.has_permission = MagicMock(return_value=True)
  355. svc_mock = MagicMock()
  356. svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudError("profile 'abc' not found (scanned 0)"))
  357. svc_mock.close = AsyncMock()
  358. with (
  359. patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  360. pytest.raises(HTTPException) as exc,
  361. ):
  362. await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer")
  363. assert exc.value.status_code == 400
  364. # --- top-level dispatcher -------------------------------------------------
  365. @pytest.mark.asyncio
  366. async def test_resolve_preset_ref_dispatches_by_source():
  367. """The public entrypoint just routes to the right tier-specific
  368. helper. Verify each branch is selected correctly."""
  369. db = MagicMock()
  370. user = MagicMock()
  371. user.has_permission = MagicMock(return_value=True)
  372. preset = MagicMock()
  373. preset.preset_type = "printer"
  374. preset.setting = '{"local": true}'
  375. db.get = AsyncMock(return_value=preset)
  376. # local
  377. out = await preset_resolver.resolve_preset_ref(db, user, PresetRef(source="local", id="1"), slot="printer")
  378. assert out == '{"local": true}'
  379. # standard
  380. out = await preset_resolver.resolve_preset_ref(
  381. db, user, PresetRef(source="standard", id="Some Bundled Name"), slot="printer"
  382. )
  383. assert json.loads(out)["inherits"] == "Some Bundled Name"